RabbitBoxをドキュメントベースAppにしたい話 その2
前回に引き続きドキュメントベースアプリ対応について書いていこうと思います。
apps.apple.comUIDocument クラス
前回の記事では比較的低レベルよりの仕様について記載しました。この記事を読んでもらうとわかりますが、ドキュメントの読み書き、変更検知など自前で実装すると結構大変です。
UIDocumentクラスはアプリデータを管理するクラス、いわゆるMVCのモデルクラスを実装するためのベースクラスです。このクラスをベースにリファレンス通り実装していくと簡単にドキュメントベースアプリ完成です。おめでとうございます🎉
UIDocument | Apple Developer Documentation
終
制作・著作
━━━━━
ⓊⓈⒼ
...となると書くことなくなるのでもう少し詳しく書いていきます。
UIDocumentの動き
UIDocumentオブジェクトはアプリ内で下図のような役割を行います。
当然といえば当然な構造で、UIDocumentクラスがファイルアクセスする際の煩雑な処理を全て隠蔽してくれます。
それとAppleデバイスでの開発経験がなかった人間の目線になりますが、次のような特徴があります。
- ファイルの Read / Write は非同期
- 基本的にファイルの読み書きは非同期で行われます
- ファイルの変更イベントを自動処理
- 例えば、開いているファイルがリネームされたり、内容が書き換えられるとUIDocumentオブジェクトへ自動反映されます
- 上記2つに関わりますが、デフォルトで排他制御を行ってくれます
- リファレンスでは Coordinated Reading / Writing と記載されています
- ファイルへ読み書きするプロシージャの実行タイミングをプロセス間レベルで調整してくれます(結果的に非同期ファイルアクセスとなる)
ドキュメントクラスの実装
UIDocumentのサブクラスを実装するためには、最低限次の2つのメソッドを実装します。
- contents(forType:)
- ドキュメントオブジェクト内のデータをファイル書き込み用にシリアライズする処理を実装します
- func load(fromContents contents: Any, ofType typeName: String?) throws
- ファイルの内容を読み込みドキュメントオブジェクト内にロードする処理を実装します
この2つの処理を実装するだけで基本的なことはUIDocumentクラスに任せて非同期かつ安全なドキュメントクラスが実装できます。
おわりに
UIDocumentクラスは前回記事のNSFileCoordinatorとNSFilePresenterを利用した実装に比べ非常に簡単な実装でドキュメント機能を実装できます。 おそらくほとんどのアプリはこのクラスの実装で十分でNSFileCoordinatorを直接使用する必要はないと思います。
ただ、RabbitBoxではcontents()とload()でどうしても回避できない次のような問題があったため、結果的にはUIDocumentクラスは利用せず、NSFileCoordinatorとNSFilePresenterによる実装を行なっています。
- ドキュメントパッケージ読み書き時に全データをメモリ上へ乗せてしまう
RabbitBoxでは数GiBレベルのドキュメントパッケージを想定しているため、デバイスのメモリを使い切ってクラッシュしてしまいます。どうしてもこの問題が回避できなかったため、本クラスを利用せず低レベルの実装を行なっています。 この件に関しては、また時間があれば書こうかと思います。
RabbitBoxをドキュメントベースAppにしたい話
前回から時間が経ちましたが、無事にRabbitBoxをDocument-based App化できたので備忘録も兼ねて書こうと思います。リリースから1月ほど経っていますが今のところ安定しています。
今回は具体的な実装方法ではなく、ドキュメントベースAppに関わる予備知識を中心に書いていきます。
apps.apple.comドキュメントベースApp
ドキュメントベースAppと言っても特殊なものではなく、PCのようにアプリデータをドキュメント(≒ファイル)として扱うAppのことです。ただ、iOS/iPad OSのファイルシステムやApple的な慣習によってWindowsやLinuxなどの開発と比べてクセがあります。
iOS / iPad OS App のデータ管理
以前のRabbitBoxもですが、ドキュメントベースAppとしてアプリを開発していない場合、アプリごとに独立したストレージ領域へしかアクセスできません。これをSandboxと呼びます。
このような構造のため、基本的にユーザはPCのようにファイル自体を直接操作する必要がありません。また、各アプリはSandbox外へのアクセスもできないため、セキュリティ的にも安全です。
ドキュメントベースAppのデータ管理
対して、ドキュメントベースAppでは次のようにSandbox外に保存したドキュメント(ファイル)への読み書きを行います。
大した違いはないように感じるかもしれませんが、アプリ内のデータ構造によってはかなり修正が必要になります。
ドキュメントベースAppに必要なデータ構造
ドキュメントベースAppでない場合はアプリ内データを自由に管理できますが、ドキュメントベースAppにする場合はある程度制限がかかります。このため、単純にSandbox外へアクセスできるようにすれば対応できるというものではありませんでした。 RabbitBoxでは次のような対応や検討を行いました。
- ドキュメント仕様の定義
- 1つのドキュメントがどのようなデータを格納するか?やその構造の明確な定義が必要です
- 例えば、サムネイルキャッシュをどうするか?ユーザープリファレンスは格納するか?
- バージョン互換性を保ったまま機能追加できるか?
- ドキュメント単体でデータとして完全か?
- 別デバイスで開いたり、Appを再インストールすると読み込めなくなる仕様ではだめ
- UserDefaults 利用の見直し
- UserDefaultsは非常に便利ですがSandbox内のデータであるためそのままでは利用できません
- 次のような対応が必要です
- ドキュメントのクローズ・オープン時に格納・復元するように実装
- UserDefaultsの利用をやめ、独自実装に変更
- 作業データ管理の検討
- 小さいドキュメントであればドキュメントオープン時にメモリへ読み込めば十分ですが、サイズが大きい場合はSandbox内に作業データ置き場が必要です
- 作業置き場とドキュメントの紐付けはどのように行うか?
ドキュメントの構造
単純なドキュメントの場合、ドキュメント=ファイル として実装できます。しかし、RabbitBoxでは大量の画像ファイルを管理するため、 ドキュメント=パッケージ として実装しています。
パッケージと書くと特殊なOS機能を利用しているかのように感じますが、実態としては単なるディレクトリ(フォルダ)です。ただ、iOS / iPad / Mac上では単一のファイルと同じように扱われます。iCloudでの同期などもパッケージ単位で行われるため単なるディレクトリ構造よりも安全に同期してくれます。
Sandbox外へのアクセス
iOS / iPad OSのアプリは原則Sandbox外へアクセスすることはできません。 (DocumentsディレクトリはSandbox内に含まれるためアクセスできます。)
Security Scoped Resources
AppからSandbox外のファイルへアクセスする場合、ファイル(ディレクトリ)ごとにSandbox内からアクセスできるようにする必要があります。
これはセキュリティスコープ付きURL(Security-Scoped URLs) の URL.startAccessingSecurityScopedResource()
を呼び出すことでアクセスできるようになります。
セキュリティスコープ付きURLは通常のURLと異なりSandbox外へのアクセス許可がついたURLです。このURLは UIDocumentPickerViewController
など一部のAPIを経由してのみ取得できます。これによって、ストレージの/
以下を片っ端からアクセスしたりすることは当然、文字列から作成したURLでもSandbox外部へは自由にアクセスできないようになっています。
NSURL | Apple Developer Documentation
また、上記のアクセス許可はアプリが再起動すると無効になります。 このため、オープンしたドキュメントのURL(ファイルパス)を単純に記憶しておくだけではセキュリティスコープ付きURLを取得することができず、ドキュメントへアクセスできなくなってしまいます。
Security Scoped Bookmark
アプリ内で永続的にSandbox外のリソースへアクセスするためにセキュリティスコープ付きURLをシリアライズ(のようなことを)することができます。これをブックマークと呼びます。ブックマークはパスに依存しない形でリソース(ファイルなど)の位置を記憶するほかセキュリティスコープに関する情報も保持します。
これらを踏まえSandbox外へのアクセスは次のような流れになります。
UIDocumentPickerViewController
でユーザにドキュメントを選択してもらうUIDocumentPickerViewController
の結果としてセキュリティスコープ付きURLを受け取る- セキュリティスコープ付きURLの
startAccessingSecurityScopedResource()
を呼び出す - ファイルへのアクセス
- アクセスが不要になったら
stopAccessingSecurityScopedResource()
で権限を解放する - セキュリティスコープ付きURLから
URL.bookmarkData()
でブックマークを作成し、UserDefaultsなどに格納 - アプリ再起動後、UserDefaultsのブックマークデータからセキュリティスコープ付きURLを生成
- 3へ戻る
ドキュメントの監視、排他制御
ドキュメントベースAppはドキュメントをSandbox外部に保存するため、他のAppやプロセスからアクセスされる可能性があります。具体的にはファイルアプリやiCloudの同期プロセスなどです。
このため、通常のアプリと異なりファイルリソースの監視や排他制御が必要になります。
iOS / iPad OS では File Coordinator と File Presenter という機構を利用してリソース監視と排他制御を実現します。
File Presenterはリソース変更イベントに対する処理を実装したクラスです。File Presenterをファイル(URL)と紐付けてOSに登録すると、各イベントに応じたメソッドが自動で呼び出されるようになります。
このような仕組みでリソースの監視が行えます。どのようなイベントを検知できるかは、ドキュメントから確認できます。
また、File Coordinatorを使用することで複数プロセスから安全にファイルアクセスできます。
NSFileCoordinatorをオブジェクトはcoordinate()メソッドにより、読み書き処理をリクエストすることができ、読み書き可能になったタイミングで非同期にクロージャがコールされます。これにより排他処理が実現できます。
なお、NSFileCoordinatorはFile Presenterと紐付けてインスタンス化します。この紐付けは省略可能なのですが、ドキュメントによると紐付け推奨のようです。主な理由はNSFileCoordinatorによるファイルアクセスのイベントをアプリ自身のFile Presenterが拾ってしまいデッドロックが発生することを回避してくれたりするようです。
おわりに
ざっくりとですが、ドキュメントベースApp対応で調べた内容を記載しました。 長々と書いていますが、実はこれらの実装を隠蔽してくれる UIDocument クラスが提供されており、こちらを使用すると簡単に実装できます。 次回はUIDocumentクラスを用いてドキュメントベースApp対応する方法について記載しようと思います。
RabbitBoxのデータをデバイス間で共有したい話 その2(iCloud編)
前回から続きまして、具体的にiCloudでどのようにAppデータを共有すればよいか調べました。色々とわからないことが多かったのですが、なんとなく概要がわかってきたので書いていきます。
apps.apple.comそもそもiCloudってなに?
当たり前のようにiCloudを利用していますが、意外とiCloudが何かわかりません。なので調べてみました。
iCloudはAppleの提供するクラウドサービスで、写真やファイルのクラウドストレージ保存をはじめ、バックアップ、メール、パスワード保存など非常に多くのサービスを提供しています。これらで消費するデータ容量は基本的にいわゆるiCloudストレージから消費されるので、共通のフレームワークで提供されているように感じますが、実際のところは仕様もドキュメントもフレームワークもバラバラで非常に難解です。
iCloudでAppデータのデバイス間同期
前述のようにiCloudには非常に多くの機能が含まれます。このため、Appデータのデバイス間同期を行う選択肢も次のように複数あります。
現在RabbitBoxではiCloud Driveを採用して開発中ですが、一通り概要を書いていきます。
iCloud(Foundation)
iCloudと書くと非常に混乱しそうなので、iCloud(Foundation) と記載しています。 次のドキュメントにある機能です。
名前の通りFoundationフレームワーク内に統合されている機能です。 おそらくiCloudサービス初期に実装されたAPIだと思うのですが、少々使いづらいです。
大きく分けてNSUbiquitousKeyValueStoreとiCloud document storageを備えています。
NSUbiquitousKeyValueStoreは単純にクラウド版UserDefaultsのようなものです。シンプルな軽量データを同期するためには一番よい選択だと思います。
iCloud document storageはFileManager経由でアクセスできる機能群で、iCloudストレージへのデータアップロード、ダウンロードなどが行えます。当初はこの機能を利用する予定でしたが、RabbitBoxではより使いやすく汎用性が高いiCloud Driveを使用することにしました。 (正確にはiCloud Drive内でiCloud document storageを使用しているとは思いますが……)
CloudKit
CloudKitは他の選択肢と毛色が異なっており、BaaS(Backend as a Service)のサービスです。iCloud上にホスティングされたデータベースを利用できるイメージです。
特徴的な機能はpublicデータベースという全ユーザがアクセスできるデータベースを利用することが可能なことで、ここのデータベースを通じてAppのデータを配信したりできるようです。
CloudKitの採用に関する参考ドキュメントがあります。
ざっくりまとめると次のような感じかと思います。
かなり有力な候補だったのですが、次のような理由からやめました。
- 画像ファイルを大量に扱うため相性が良くなさそう
- iOS以外のサポートも一応選択肢として残しておきたかった
- 実現したいことに対してオーバースペック
最終的には次に紹介するiCloud Driveを利用する方式に決めました。
iCloud Drive
他の選択肢と異なり、App側にiCloudによる同期処理を実装するものではありません。 Appをドキュメントファイルの保存、読み込みに対応させ、そのドキュメントをiCloud Drive上に保存することで複数デバイス間でのデータ共有を実現する方法です。
iOS/iPad OS AppのiCloud対応で調べても意外と出てこなかったのですが、 PC向けアプリ界隈では非常に自然な仕様です。
この方式のメリットは次のようなものです。
これで万事解決じゃーん🎉 なんて考えていましたが、この後いろいろ苦労します……
Document-based App地獄編へ続く
RabbitBoxのデータをデバイス間で共有したい話 その1
本格的にiPhoneの最適化を行おうと思っていますが、 その前にiCloudなどを利用してデバイス間でのデータ共有をサポートできないか 検討してみることにしました。
結果的にRabbitBoxの内部構造を再設計し、 ドキュメントベースAppにすることが最も効果的そうなので各種実験と実装を行なっています。地獄です😇
現在はなんとか形になってきていますが、かなり紆余曲折がありましたので数回に分けて記事にしようと思います。
apps.apple.comそもそもの経緯
RabbitBoxのような画像管理を複数デバイスで共有する場合、アプリケーションサーバを実装しまって集中管理した方が整合性とりやすそうですし、AndroidやWebブラウザ対応など将来性もあるよなぁと思います。 ただ、趣味の延長で開発していることもあり、バックエンドサービスの運用は行いたくないというのが本音です。 RabbitBoxはあくまでスタンドアロンアプリとして開発を継続するつもりです。
このような理由もあり、現バージョンのRabbitBoxはローカルデバイス内で完結するアプリとして開発しています。ただ、私本人もなのですがiPadとiPhoneでデータを共有したい場面は多々あり、デバイス間のデータ共有を正式サポートしようと思い立ちました。
共有方法の検討
デバイス間のデータ共有方法は開発初期からなんとなく考えていましたが、具体的な手法については検討していませんでした。ぼんやりとした案としては、
のようなものを考えていました。
Dropbox APIを使う
DropboxのAPIを使用するとかなり自由なことができそうでした。 ただ、全員がDropbox使っているわけではないですし、特定のサービスに依存するのもどうかと考えて保留としました。
ImageBoxのエクスポート・インポート機能でやり過ごす
これは最有力でした。実装も比較的簡単ですし、今のRabbitBoxの内部設計的にも対応は難しくありません。 ただ、実際にテスト実装してみると次のような問題があり、断念しました。
- iPadのImageBoxをiPhoneで少し見たいという場合でも全データの「エクスポート→iCloudなどでの転送→インポート」が必要
- インポートするデータとApp内のImageBoxデータで約2倍のストレージを消費する
- デバイス間でのやり取り(iCloud同期も含め)時に全データの転送が必要(1枚画像が追加されただけでも全画像データの転送が必要になる)
iCloudを使う
最終的にはこの方向で進めています。
ただ、情報が少ない……というか分かりづらい😇
次回以降はiCloud周りで調べた内容を備忘録も兼ねて書いていこうと思います。
RabbitBox v1.9.0 - v1.12.0 の更新内容
アプリの方はちょこちょこ更新していたのですが、エルデの王を目指すのに忙しくこちらを更新できていませんでした。
新機能の紹介を中心にまとめて更新内容を紹介していこうと思います。
apps.apple.com- 一部Plus機能の体験機能
- 画像比較機能(Plusのみ)
- ダウンロード済みアイコン(Plusのみ)
- タグのコピー&ペースト機能
- タグ一覧のキーワードフィルタ機能
- その他の機能追加および変更
- 不具合修正
- 今後の開発予定
一部Plus機能の体験機能
一部のPlus機能を1日3回まで使用できるようになりました。
Plus機能を実装しても誰にも利用されない🥲ので開発モチベーション維持も兼ねて実装してみました。 回数や対象機能の範囲は調整していこうと思っています。
画像比較機能(Plusのみ)
類似画像を比較する機能を追加しました。
この機能では複数の画像を素早く切り替えながらチェックできます。 削除、グループマークを設定してからApplyボタンをタップすることでそれぞれの処理を適用できます。
ダウンロード済みアイコン(Plusのみ)
ImageBox内に存在する画像ファイルがダウンロード候補にある場合、アイコンが表示されます。
タグのコピー&ペースト機能
画像に付与されている全てのタグをクリップボードへコピーします。 コピーしたタグは他の画像へペーストできます。
タグ一覧のキーワードフィルタ機能
タグ一覧をキーワードでフィルタできるようになりました。
Downloadタグ内の子タグが増えてきた際などに文字列で表示を絞り込めます。
その他の機能追加および変更
- ImageBox一覧を並べ替えられるようになりました
- ロングタップで自由に移動させられます
- Webブラウザが前回表示していたURLを記憶するようになりました
- ImageBoxごとに記憶します
- iPhoneなどスクリーンの小さいデバイス上において、ダウンロード候補一覧サムネイルのサイズを小さくしました
- 親タグ変更ボタンをタグ情報の編集フォーム内へ移動させました
不具合修正
- イメージ検索機能適用中にタグ編集などが正しく行えない不具合を修正しました
- 親タグ変更ボタンが正しく機能しない不具合を修正しました
- カラムレイアウト時のイメージ一覧表示時に先頭の画像が表示されない不具合を修正しました
- iPhoneなどを横向きで撮影した画像が正しい向きで表示されない不具合を修正しました
- その他、クラッシュや小さな不具合を修正しています
今後の開発予定
使い勝手の改善を中心に今後も開発を続けていく予定です。 また、現在問題なのがiPhone環境での使い勝手で、改善が必要と考えています。
RabbitBoxはiPad用アプリとして開発しており、おまけ程度にiPhone対応も行っています。このため、お世辞にも使い勝手が良いとは言えないのですが、実情はiPhoneでのダウンロードが8割程度となっており、残念ながらほとんどのユーザにおまけ程度で対応しているUIで使用されているようです。 あくまでiPadをメインターゲットにしますが、iPhoneでのUI改善も注力していく予定です。
RabbitBox v1.7.0 & v1.8.0
アプリをバージョンアップしたら記事にしようと思ってたんですが、v1.7.0は書いてませんでした。
ということでまとめて更新内容を紹介していこうと思います。
apps.apple.comランダムソート機能
イメージ一覧時のソートにランダムを追加しました。 選択するとランダムソートします。
ストレージ使用量の確認機能
設定画面からストレージの使用状況を確認できるようになりました。
Total Image Sizeは管理している全画像ファイルの合計サイズです。
DB Sizeは内部で使用しているデータベースのサイズです。 タグやイメージの検索情報を格納しています。 どちらかといえばデバッグ向け機能で通常は見てもあんまり意味ありません。
また、キャッシュは次の2種類が表示されています。
- Thumbnail Cache
- 画像やタグ一覧などに使用されているサムネイル画像です。
- Download Cache
- Webページの画像を一覧表示するために一時的に保存している画像です。
- アプリ起動時に自動削除されます。
タグ編集機能の改善
タグの編集機能を使いやすくしました。タグの候補表示が見づらかったりしていたのですが、見た目を調整しています。
また、詳細パネルから直接タグを編集できるようになりました。
イメージ再圧縮機能(サブスクリプションのみ)
イメージの再圧縮機能を追加しました。 これは管理しているイメージをJPEG形式で再圧縮し、使用容量を削減する機能です。 現状それほど高機能ではありませんが、高画質PNGなどをシンプルにJPEG圧縮してファイルサイズを削減できます。
なお、再圧縮機能を使用するとオリジナルファイルは削除されます。このため、再圧縮した画像を元に戻すことはできません。
SwiftUIでタップがうまく反応しなかった話
RabbitBox v1.6.0で修正した不具合なのですが、Viewの一部がタップできない不具合がありました。この挙動が若干謎で同じViewでも内容によって発生したり発生しなかったり、またタップできない範囲が変わったりとよくわかりませんでした。
今回の記事ではその不具合の原因と修正方法について書きます。
apps.apple.com発生していた不具合
下図のようにタグを一覧するビューで赤枠のように場所がなぜかタップしても反応しないという問題が発生していました。
このビューは次のようにHeaderViewとTagListViewをZStackで重ねて表示しています。 TagListViewをスクロールした際にHeaderを上に隠す機能を実装するため、このような構造になっています。
原因
原因はHaderViewに表示しているImageがタップイベントを奪っていたせいでした。 イメージとしては下図のように本来トリミングしているはずの見えないImageにタップ判定が残っていたため、その部分と重なっているTagアイテムがうまくタップできない状態です。
簡単なコードで表現すると次のような実装になります。GeometryReaderでViewのサイズを取得し、それに応じてImageをリサイズしています。
import SwiftUI struct HeaderView: View { var body: some View { GeometryReader { gr in VStack { Image(systemName: "mic.square").resizable().aspectRatio(1, contentMode: .fill).frame(width: gr.size.width, height: 100).clipped() Text("Header Title") } } } } struct ContentView: View { var body: some View { ZStack { VStack { Spacer(minLength: 180) HStack { Spacer() Image(systemName: "hand.tap.fill").onTapGesture { print("TAP!") } Spacer() } } VStack { HeaderView().frame(width: 400, height: 150).onTapGesture { print("HEADER!!") }.background(.blue) Spacer() } } } }
上記コードを実行すると次のような画面になります。
指のマークをタップすると"TAP!"と表示されることを期待しますが、実際には"HEADER!!"が表示されます。
解決方法
ほかにもっと良い解決方法がありそうな気がしますが、とりあえず単純にImageがタップイベントに反応しないようにする方法で解決しました。
具体的には次のようにHeaderView側のImageに allowsHitTesting(false) を追加します。
Image(systemName: "mic.square").resizable().aspectRatio(1, contentMode: .fill).frame(width: gr.size.width, height: 100).clipped().allowsHitTesting(false)
これにより、HeaderViewからはみ出しているImageがタップイベントを奪うことがなくなり見た目通りの反応になります。
clipped()でImage自体は切り取られているっぽいんですが、使い方が悪いんでしょうか?なんかモヤモヤしますがとりあえず現状で回避しています。
iOSのバージョンアップとかで挙動変わるかもしれませんし覚えておかないと……