App Groups で共有したファイルを監視してプロセス間の変更を Combine で検知する

## 背景

### App Groups とプロセス間通信

https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups

> App groups allow multiple apps produced by a single development team to access shared containers and communicate using interprocess communication (IPC).

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112629/ae03cb5f-fcf3-2119-9e7d-8c496cc6e6b4.png" width="600">

App Groups は複数のアプリケーションが同じ領域にアクセスできるようにする仕組みです。異なるアプリケーションは別のプロセスで動いているので、プロセス間通信(IPC)を実現する仕組みとも言えます。アプリケーションの本体と App Extension も別のプロセスで動いています。

共有している情報の変更をリアルタイムで検知して Combine などで変更を伝えてあげられるようになると、SwiftUI などからも簡単に使えるし便利です。タイマーなどを使用して定期的に変更を調べることもできますが、もう少しスッキリした方法がないか試してみました。


### App Groups と UserDefaults

App Groups では、プロセス間で UserDefaults を共有することができます。共有する UserDefaults は `UserDefaults(suiteName: groupId)` で取得できます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112629/1abebf80-3375-1c8e-7779-aee223c6ea54.png" width="600">

`UserDefaults.standard` で取得した UserDefaults は KVO で監視ができますが、`UserDefaults(suiteName: groupId)` で取得した UserDefaults は KVO で監視ができません。

Apple は[できるようになったと書いている](https://developer.apple.com/library/archive/releasenotes/Miscellaneous/RN-Foundation-OSX10.12/index.html)のですが、できません。

> changes from other processes (such as defaults(1), extensions, or other apps in an app group) were ignored by KVO. These limitations have both been corrected.

僕も試してみたのですが、[先人の結果](https://stackoverflow.com/questions/66048925/observation-of-nsuserdefaults-does-not-work-when-using-initsuitename)と同じように KVO は動きませんでした。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112629/94b077a7-b588-b179-b5c0-7682ad7fc9e7.png" width="600">

### App Groups とコンテナURL

App Groups では UserDefaults だけではなく、コンテナURLから共通で利用できるディレクトリを取得することができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112629/9cd2c13b-9103-6e50-d49a-cda245b00bf5.png" width="600">

コンテナURL上で共有しているファイルの変更を監視することで、Combine に変更を伝えてあげられないかと考えました。


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112629/f7de023a-de01-12f5-3f0f-11b8e4c9a6b0.png" width="600">


## 実装 - 準備編

App Groups で必要となる Group ID などは設定済みであるとします。例として、Broadcast Upload Extension で配信をしているかどうかをアプリ本体に伝える実装を考えます。

### コンテナURLの取得

コンテナURLは1行で取得できるのですが、少し記述が面倒なのでまとめておきます。

```swift
public class AppGroupsContainer {
    public static let shared = AppGroupsContainer()
 
    private let groupId: String = GROUP_ID
    
    public var containerUrl: URL {
        guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupId) else {
            fatalError("Failed to get app groups container.")
        }
        return url
    }
}

```

### ファイルに値を書き込む

ファイルへの書き込みは `Data` に変換すればできるので、`Codable` に準拠していればなんでもいいものとします。

```swift
private lazy var encoder = JSONEncoder()

func write(_ value: Codable) throws {
  let data = try encoder.encode(value)
  try data.write(to: url)
}
```

### ファイルの変更を監視する

URL からファイルをオープンしてから `DispatchSourceFileSystemObject` によってファイルの変更を監視できます。変更を検知したら `EventHandler` で変更があったことを伝えます。

```swift
public enum FileObserverError: Error {
    case failedToOpenFile
}

public class FileObserver {
    public typealias EventHandler = (FileObserver, URL) -> ()
    
    private let url: URL
    private var fileSystemObjectSource: DispatchSourceFileSystemObject?
    private let eventHandler: EventHandler
    
    public init(url: URL, eventHandler: @escaping EventHandler) throws {
        self.url = url
        self.eventHandler = eventHandler
        try observe()
    }
    
    deinit {
        // 掃除
        if let fileSystemObjectSource = fileSystemObjectSource {
            fileSystemObjectSource.setEventHandler { }
            fileSystemObjectSource.cancel()
            close(fileSystemObjectSource.handle)
        }
    }
    
    private func observe() throws {
        // ファイルを開く
        let fileDescriptor = open(url.path, O_EVTONLY)
        guard fileDescriptor >= 0 else {
            throw FileObserverError.failedToOpenFile
        }
        let fileSystemObjectSource = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fileDescriptor,
            eventMask: .write // 書き込みを監視
        )
        fileSystemObjectSource.setEventHandler { [weak self] in
            guard let self = self else { return }
            // 変更があったことを伝える
            self.eventHandler(self, self.url)
        }
        // 監視を開始
        fileSystemObjectSource.resume()
        self.fileSystemObjectSource = fileSystemObjectSource
    }
}
```

## App Groups にある監視対象のファイルを抽象化する

ファイルの書き込み、読み込み、監視、@Published な値の管理をまとめて、監視するファイルを抽象化したクラスを作成します。

与えた URL 上にあるファイルに保存される値の型 `T` は `Codable` に準拠しています。

```swift
public class AppGroupsFile<T: Codable> {
    /// ファイル名
    private let name: String
    /// 保存先
    private let url: URL

    private var observer: FileObserver?

    @Published public var value: T
    
    private lazy var encoder = JSONEncoder()
    private lazy var decoder = JSONDecoder()
    
    public init(
        name: String,
        initialValue: T
    ) throws {
        self.name = name
        self.url = AppGroupsContainer.shared.containerUrl.appendingPathComponent(name)
        self.value = initialValue
        // 監視対象のファイルがなければファイルを生成して初期値を書き込む
        if !FileManager.default.fileExists(atPath: url.path) {
            try write(initialValue)
        }
        self.observer = try FileObserver(url: url) { [weak self] observer, url in
            self?.handleFileEvent(observer: observer, url: url)
        }
        updateValue()
    }
    
    /// 値の書き込み
    public func write(_ value: T) throws {
        let data = try encoder.encode(value)
        try data.write(to: url)
    }
    
    /// 値の読み込み
    public func read() throws -> T {
        let data = try Data(contentsOf: url)
        let value = try decoder.decode(T.self, from: data)
        return value
    }
    
    /// ファイルの変更を検知したら呼ばれる
    private func handleFileEvent(observer: FileObserver, url: URL) {
        updateValue()
    }
    
    private func updateValue() {
        do {
            let value = try read()
            /// @Published な value を変更
            self.value = value
        } catch {
            print("Failed to read value from app container file.")
        }
    }
}
```

## 実装 - 実用編

上記で用意した仕組みを利用して、アプリケーション本体と Broadcast Upload Extension 間で情報を共有する実装を試してみました。すごくシンプルに、Broadcast Upload Extension が配信を開始したらフラグを ON にして、配信が終了したらフラグを OFF にします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112629/1dc9a53f-b292-aca4-cee0-3c32f7b2eb6b.png" width="600">

### 共有する情報の定義

アプリケーションと Broadcast Uplaod Extension 間で共有する情報が明確になるように、パッケージを作成して共通でインポートしておきます。

```swift
public class BroadcastPreferences {
    public static let shared = BroadcastPreferences()
    
    /// 配信しているかどうか
    public let isBroadcasting: AppGroupsFile<Bool>
    
    init() {
        do {
            isBroadcasting = try AppGroupsFile<Bool>(name: "is_broadcasting", initialValue: false)
        } catch {
            fatalError("Failed to initialize BroadcastPreferences with error: \(error)")
        }
    }
}
```

### Broadcast Upload Extension 側の処理

Broadcast Upload Extension では、配信処理が開始したらフラグを ON にして、終了したら OFF にします。

```swift
class SampleHandler: RPBroadcastSampleHandler {
    override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) {
        try! BroadcastPreferences.shared.isBroadcasting.write(true)
    }
    
    override func broadcastFinished() {
        try? BroadcastPreferences.shared.isBroadcasting.write(false)
    }
    
    override func finishBroadcastWithError(_ error: Error) {
        try? BroadcastPreferences.shared.isBroadcasting.write(false)
    }
// ...
}
```

### アプリケーション(SwiftUI)側の処理

Broadcast Upload Extension の変更をアプリケーションの SwiftUI 上で検知できるようにします。ViewModel を定義して、SwiftUI 上では値の変更があればテキストが切り替わるようになっています。

```swift
class ContentViewModel: ObservableObject {
    @Published var isBroadcasting: Bool = false
    
    private var cancellables: [AnyCancellable] = []
    
    init() {
        cancellables += [
            BroadcastPreferences.shared.isBroadcasting.$value
                .receive(on: DispatchQueue.main)
                .sink { [weak self] isBroadcasting in
                    guard let self = self else { return }
                    self.isBroadcasting = isBroadcasting
                }
        ]
    }
}

struct ContentView: View {
    @ObservedObject private var viewModel = ContentViewModel()
    
    var body: some View {
        VStack(spacing: 6) {
            Text("Status")
                .bold()
            Text(viewModel.isBroadcasting ? "Broadcasting" : "Not Broadcasting")
                .bold()
                .foregroundColor(.gray)
        }
        .padding()
    }
}
```

### 挙動

配信の開始/終了を検知して画面中央のテキストが切り替わりました！

![status.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112629/f5d6b312-3429-7fc8-1185-6b61d8c97f37.png)

## まとめ

今回のコードはサンプルプロジェクトにまとめて GitHub に公開しています。

https://github.com/naru-jpn/ObservableAppGroups

アプリケーションと App Extension 間の距離を少しは近づけられたかなと思います。`BroadcastPreferences` の初期化時に `fatalError` を使ってしまっているところとかサンプルなのでまあいいやと思ってこうしてしまっていますが、もう少し安全にしないと実際には危なくて使えないですよね。 

SwiftUI と Combine 初心者なのでツッコミどころがあったらぜひお願いします。