13
Help us understand the problem. What are the problem?

posted at

updated at

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

Tl;Dr

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

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

現時点の機能

Property List でサポートされている型に加え、URLImage、JSONシリアライズされたData型にも対応しています。

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

閲覧・検索

閲覧 検索 App Groups

編集・削除

JSONでの編集 削除 Dateの編集
image.png image.png image.png

エクスポート・コピー

Xcode コンソールへの出力。

image.png

UserDefaults を一括で出力。(ほぼ Swift コード)

image.png

導入

README にも記載していますが、Swift Package Manager で提供しているので、Xcode か Package.swift で追加し、ランチャー用のアイコンをセットアップするコードを追加すれば完了です。

SwiftUI の場合
import UserDefaultsBrowser

@main
struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            UserDefaultsBrowserContainer { // ✅ ルート View を囲む
                ContentView()
            }
        }
    }
}
UIKit の場合
import UserDefaultsBrowser

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

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

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

image.png

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

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

カスタマイズ
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

開発のキッカケ

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

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

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

image.png

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

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

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

開発の流れ

私は SwiftUI 向けに SwiftUI-Simulator というツールを開発していたので、この OSS のマイルストーンに組み込むことにしました。

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

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

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

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

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

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

そこで、外部の Swift Package として切り出し、UIKit 向けの API を追加した OSS を UserDefaults-Browser 1.0.0 としてリリースしました。

仕組みについて

UserDefaults に用意された dictionaryRepresentation() というメソッドで、すべてのキーと値を参照できたので、それを利用してすべての値を取得して、適切な形式で表示するようにしています。

ただ、これだとシステム側で用意されたキー・値も取得されてしまうため、それについてはコード側で判定を入れて振り分けています。

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や画像についてもサポートしています。

それらについては、その型で解釈できるかデコードを試み

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)
    }
}

あとはデータ型に応じて整形して表示しています。

データ型に応じて整形して表示
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 を新しく作成することで実現しています。

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 の動作確認は自分の小さなアプリでしか試せていませんので、細かいエッジケースに対応できていないかと思います。

不具合については IssuePR などを頂けると大変助かります。

また、改善提案や要望については GitHub Discussions も用意していますので、お気軽に書き込みいただければ幸いです🙏

蛇足

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

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
13
Help us understand the problem. What are the problem?