[iOS] アプリ上で UserDefaults の内容を確認・編集できる OSS を作った話

## Tl;Dr

任意の iOS アプリに組み込んで利用可能なデバッグツール的な OSS です。

https://github.com/YusukeHosonuma/UserDefaultsBrowser


以下ツイートの動画を見ていただくのが分かりやすいかと思います。

https://twitter.com/tobi462/status/1523243705167392768?s=20&t=A0qtLeIsSduMRgBh5cGIkg

## 現時点の機能

[Property List でサポートされている型](https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/PropertyList.html)に加え、`URL`や`Image`、JSONシリアライズされた`Data`型にも対応しています。

App Groups にも対応しています。

### 閲覧・検索

|閲覧|検索|App Groups|
|--|--|--|
|<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/69bab644-1756-4610-8daa-4c9993c19714.png" />|<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/1b51f5dd-f6ab-dd5b-e27f-3f413eb4ea88.png" />|<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/84866559-dac0-1a10-b9e0-9b34e47fe4f7.png" />|

### 編集・削除

|JSONでの編集|削除|Dateの編集|
|--|--|--|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/16199c67-8de2-14bb-1659-c5c83205d850.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/f6d9bc4a-fe7c-f1b2-586a-1044d6b3a85b.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/c0b59ee5-e0db-156e-c03f-34a012382710.png)|

### エクスポート・コピー

Xcode コンソールへの出力。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/1be538c7-3010-0c6e-cac6-103b6ce0aca7.png)

UserDefaults を一括で出力。（ほぼ Swift コード）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/7a4a0cc1-16fa-aaa1-e032-4c19d9a8992e.png)

## 導入

[README](https://github.com/YusukeHosonuma/UserDefaultsBrowser/blob/main/README.md) にも記載していますが、Swift Package Manager で提供しているので、Xcode か `Package.swift` で追加し、ランチャー用のアイコンをセットアップするコードを追加すれば完了です。

```swift:SwiftUI の場合
import UserDefaultsBrowser

@main
struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            UserDefaultsBrowserContainer { // ✅ ルート View を囲む
                ContentView()
            }
        }
    }
}
```

```swift:UIKit の場合
import UserDefaultsBrowser

class ViewController: UIViewController { // 💡 ルート ViewController

    override func viewDidLoad() {
        super.viewDidLoad()
        UserDefaultsBrowser.setupUserDefaultsBrowserLauncher() // ✅ 追加
    }
}
```

画面左下に起動用のアイコンがセットアップされるので、あとはそれをタップすれば画面を開けます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/b7dbcef0-71de-5812-9324-c8f3460f358a.png)

開発時の常駐ツールとして利用する際は、`#if DEBUG`などを併用するとよいかと思います。

App Groups の ID やアイコンの見た目をカスタマイズするオプションも含まれています。

```swift:カスタマイズ
UserDefaultsBrowserContainer(
    suiteNames: ["group.xxx"],                        // App Groups の ID を配列で指定
    excludeKeys: { $0.hasPrefix("not-display-key") }, // 表示から除外したいキーをクロージャで指定
    accentColor: .orange,                             // アイコンの表示色
    imageName: "wrench.and.screwdriver",              // アイコンの画像名（SF Symbols で指定）
    displayStyle: .fullScreen                         // シート表示またはフルスクリーン表示（`.sheet` or `.fullScreen`）
)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/06fb601b-aaee-7f22-e140-fd8012cd49fe.png)

## 開発のキッカケ

最近、SwiftUI でアプリ開発をしている際に UserDefaults に保存されている値を確認したいケースがありました。

調べてみるとどうやらシミュレータであれば、特定のディレクトリに`.plist`として保存されていることが分かりました。

https://qiita.com/hyosang813/items/d5d06cf6428a9c13856c

そんなわけで無事に`.plist`で値を確認できたのですが、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29653/ef6a26da-3a0c-211f-7640-1c7793a3b17f.png)

あまり見やすくない、と感じました。

JSONシリアライズされた`Data`型については確認することもできませんし、実機ビルドした際にすばやく確認する方法は見つけられませんでした（社内で実機テストをしていて不具合が見つかった際に、ちょっと確認できたら便利なケースは多いはずです）。

そんなわけで、アプリ上で UserDefaults の内容を確認できるツールを作ることに決めました。

## 開発の流れ

私は SwiftUI 向けに [SwiftUI-Simulator](https://github.com/YusukeHosonuma/SwiftUI-Simulator) というツールを開発していたので、この OSS のマイルストーンに組み込むことにしました。

SwiftUI-Simulator としてもっと開発に注力すべき機能も多かったのですが、UserDefaults の閲覧くらいであれば対して時間もかからないだろうと予想し、キリの良いタイミングで気分転換も兼ねて開発に着手しました。

そして、閲覧機能が完成したので 1.5.0 のリリースに含めました。

https://twitter.com/tobi462/status/1520600239035793408

これで個人的には満足したのですが、ふと「編集機能の需要ってあるだろうか？」と気になり、Twitter で初のアンケートを取ってみることにしました。

https://twitter.com/tobi462/status/1521164708254187520?s=20&t=pPxrTuUn2WbbR7tKmfYAMQ

アンケートの結果、値の変更をしたいと思ったことがある人がそれなりにいることが分かり、せっかくなので開発してしまうことに決めました。

そして、App Groups のサポートも含めた編集機能を 1.6.0 としてリリースしました。

https://twitter.com/tobi462/status/1522068144218869760?s=20&t=pPxrTuUn2WbbR7tKmfYAMQ

さて、SwiftUI Simulator は SwiftUI に限定された OSS ですが、この UserDefaults の閲覧・編集機能は UIKit でも問題なく利用できるはずでした。

そこで、外部の Swift Package として切り出し、UIKit 向けの API を追加した OSS を [UserDefaults-Browser](https://github.com/YusukeHosonuma/UserDefaultsBrowser) 1.0.0 としてリリースしました。

https://github.com/YusukeHosonuma/UserDefaultsBrowser

## 仕組みについて

UserDefaults に用意された [dictionaryRepresentation()](https://developer.apple.com/documentation/foundation/userdefaults/1415919-dictionaryrepresentation) というメソッドで、すべてのキーと値を参照できたので、それを利用してすべての値を取得して、適切な形式で表示するようにしています。

ただ、これだとシステム側で用意されたキー・値も取得されてしまうため、それについては[コード側で判定](https://github.com/YusukeHosonuma/UserDefaultsBrowser/blob/1.0.0/Sources/UserDefaultsBrowser/Core/UserDefaultsContainer.swift#L10-L27)を入れて振り分けています。

```swift
private let userDefaultsSystemKeys: [String] = [
    "AddingEmojiKeybordHandled",
    "CarCapabilities",
    ...
]

private let userDefaultsSystemKeyPrefixes: [String] = [
    "Apple",
    "com.apple.",
    ...
]

struct UserDefaultsContainer: Identifiable {
    var allKeys: [String] {
        Array(
            defaults.dictionaryRepresentation().keys.exclude {
                isOSSKey($0) || excludeKeys($0) // 💡 OSS が管理しているキー（開閉状態の保持など）も除外
            }
        )
    }
    ...
```

これは私の環境で確かめられたものを列挙しているだけですので、不足などがあれば Issue や PR を頂けるとありがたいです（あるいは他に良い方法があれば教えて頂けると助かります）。

この OSS では Property List でサポートされた形式に加え、JSONシリアライズされた`Data`や画像についてもサポートしています。

それらについては、[その型で解釈できるかデコードを試み](https://github.com/YusukeHosonuma/UserDefaultsBrowser/blob/1.0.0/Sources/UserDefaultsBrowser/Extension/UserDefaults%2B.swift#L17-L63)、

```swift:JSONデコードを試みている箇所
extension UserDefaults {
    func lookup(forKey key: String) -> Any? {
        ...
        //
        // Data
        //
        if let data = value(forKey: key) as? Data {
            ...
            //
            // JSON encoded Data
            //
            if let decoded = try? JSONSerialization.jsonObject(with: data), let dict = decoded as? [String: Any] {
                return JSONData(dictionary: dict)
            }
        }

        //
        // JSON encoded String
        //
        if let string = string(forKey: key),
           string.hasPrefix("{"),
           string.hasSuffix("}"),
           let dict = string.jsonToDictionary()
        {
            return JSONString(dictionary: dict)
        }

        return value(forKey: key)
    }
}
```

あとは[データ型に応じて整形して表示](https://github.com/YusukeHosonuma/UserDefaultsBrowser/blob/1.0.0/Sources/UserDefaultsBrowser/View/RowView.swift)しています。

```swift:データ型に応じて整形して表示
private var value: Value {
    let value = defaults.lookup(forKey: key)

    switch value {
    //
    // 💡 Note:
    // `Array` and `Dictionary` are display as JSON string.
    // Because editor of `[String: Any]` is input as JSON.
    //
    case let value as [Any]:
        return .text(
            value.isEmpty ? "[]" : value.prettyJSON
        )
    case let value as [String: Any]:
        return .text(
            value.isEmpty ? "{}" : value.prettyJSON
        )
    case let value as JSONData:
        return .decodedJSON(value.dictionary.prettyJSON, "<Decoded JSON Data>")
    case let value as JSONString:
        return .decodedJSON(value.dictionary.prettyJSON, "<Decoded JSON String>")
    case let value as UIImage:
        return .image(value)
    case let value as Date:
        return .text(value.toString())
    case let value as URL:
        return .url(value)
    case _ as Data:
        return .data(prettyString(value))
    default:
        return .text(prettyString(value))
    }
}
```

編集についても基本的には似たようなことをやっているだけですので、記事での解説は割愛させていただこうと思います（それほど難しいコードもありませんので、興味のある方はソースコードを読んでいただければと思います）。

UI についてはすべて SwiftUI で作られています（逆に言うと、SwiftUI の生産性があったからこそ作ろうと思ったとも言えます）。

そのため SwiftUI への API 提供は簡単だったのですが、UIKit の場合にどうやってランチャー用のボタンをセットアップするかに少し悩みました。

結論から言うと、常に最前面に常駐させるために `UIWindow` を新しく作成することで実現しています。

```swift:EntryPoint.swift
private var overlayWindow: UIWindow?

public func setupUserDefaultsBrowserLauncher(...) {
    if let rootWindow = UIApplication.rootWindow, let scene = UIApplication.rootScene {
        let window = UIWindow(windowScene: scene)
        window.backgroundColor = .clear
        window.frame = CGRect(
            origin: CGPoint(
                x: 0,
                y: UIScreen.main.bounds.height - (overlayWindowSize.height + rootWindow.safeAreaInsets.bottom)
            ),
            size: overlayWindowSize
        )
        window.windowLevel = .alert + 1

        let vc = UserDefaultsBrowserLauncherViewController(...)
        window.rootViewController = vc

        window.makeKeyAndVisible()
        overlayWindow = window
    }
}
```

基本的な実装の仕組みとしてはこんなところでしょうか。

## 感想

やはり SwiftUI の生産性は非常に高いと感じました。

とくにこのような見た目にそれほど拘る必要がないツール系については、かなりのスピードで開発できてしまうので、今後 macOS の SwiftUI サポートが充実すれば（現時点の 3.0 では単体では使い物にならないレベルだと感じます）、多くのエンジニアが様々な開発者向けツールを作るようになるのではないかと思います。

## Contributions

この OSS の動作確認は自分の小さなアプリでしか試せていませんので、細かいエッジケースに対応できていないかと思います。

不具合については [Issue](https://github.com/YusukeHosonuma/UserDefaultsBrowser/issues) や [PR](https://github.com/YusukeHosonuma/UserDefaultsBrowser/pulls) などを頂けると大変助かります。

また、改善提案や要望については [GitHub Discussions も用意](https://github.com/YusukeHosonuma/UserDefaultsBrowser/discussions/10)していますので、お気軽に書き込みいただければ幸いです🙏

## 蛇足

記事に Twitter の埋め込めるのは便利だと思うのですが、リツイートも含めて表示されてかなりの領域を取ってしまうので、なんだか宣伝行為っぽく見えてしまうのが難点ですね；