🌩️

個人開発のノートアプリをiCloud対応させるために必要だったこと

2020/10/11に公開
1

個人開発でやってるLike PaperというノートアプリをiCloud対応させて、ver2.0.0としてリリースしました。

https://apps.apple.com/jp/app/like-a-paper/id1511690088#?platform=ipad

また、ver2.0.0から、OSSとしてGithubで公開することにしたので、下記で見れます。

https://github.com/0si43/LikePaper

この記事では、作業ログ的にiCloud対応の際にぶちあたった問題と解決法を書いていこうと思います。

そもそもの課題

元々Like Paperはデバイス内のDocumentsディレクトリにファイルをつくっていました。
「そこらへんにある紙の裏に走り書きする感覚」を実現するアプリだったので、もし必要なら共有機能使って別アプリにエクスポートしてね、という思想でした。

ただこの状態だと、

  • アプリを消すとノートが全て消える
  • 端末を変えると全て消える
  • 別端末からアクセスできない

などの問題がありました。

将来的には端末内ではなく、どこか外にデータ持たせないとダメだろうなと思っていました。
(ちなみにver1.0だとサーバー通信一切してませんでした。これ結構すごいですよね。故にめちゃくちゃクラッシュ少なかったです)

選ばれたのは、iCloud

外部にデータ持たせる方法は無数にあると思います。
Firebase使うもよし、AWS使うもよしでしたが、ちゃんとAPI構築すんのはめんどくさいなあという気持ちがありました。

幸い、Like PaperはiOS版オンリーなので、Apple ID単位でなんとかデータ保持させたいなと考えました。
Apple IDでサインインして、iCloud上にファイルを書けないか、という方針で調査しました。

当初Sign in with Appleを実装しなきゃいけないイメージでしたが、 調べたらサインインは不要で、端末でiCloudの設定が有効になっていると、デバイス上のアプリからはパスが取得できるので、そこに書けば良いだけでした。

そもそもiOSアプリのファイルの書き方

実はver1.0ではなんとなくでファイル保存してたので、iCloud移行にともなってちゃんと勉強しました。
下記がよくまとまっていると思いました。

iOSでデータを永続化する方法

まずファイルに保存する形式が二種類あります。

  • アーカイブ
  • プロパティリスト

の二通りがあります。
アーカイブはData型にしてwriteするやつ。
プロパティリストにするのは.plistファイルとして書きこむやつです。

Like Paperのファイル保存はAppleの公式サンプルの保存ロジックを パクった参考にしたので、プロパティリストで保存されていました。

デバイス内に書けるディレクトリはいくつかあるんですが、ユーザーに見せても良いファイルであればDocuments/に、見せたくないシステムファイルはLibrary/に書くというのが基本っぽいです。
メインバンドル(要はXcodeでプロジェクトファイル開いたときに見られるファイル)にはWrite権限がないので、気をつけてください。
(デフォルトファイルをアプリ内に組みこんでおいて、それをユーザーが更新していくようにつくろうとしても、直接は更新ができません)

ファイルとして書くのはそれなりにめんどくさいので、Appleは便利なデータ永続化の仕組みを用意してくれています。
UserDefaults, Core Data, Keychainなどがあります。
Realmというモバイルに特化したDBもあるそうなのですが、僕は使ったことがありません。

iCloudでファイル書く

iCloudでファイルを書くときは、三つの選択肢があります。

  • KVS
  • iCloud Documents
  • CloudKit

KVSはUserDefaultsのiCloud版みたいなものっぽいです。
今回の用途(数MB程度のファイルを書きたい)であれば、iCloud DocumentsかCloudKitの二択でした。

CloudKit

CloudKitはFireStoreのApple版みたいなイメージでとらえています。
ダッシュボードで利用状況がモニタリングできて、使い方もそんなに難しくなさそうだったのですが、
保存形式がDBになっちゃうのと、全ユーザーのノートのデータが僕の管理してるストレージに格納されてしまうのが、ちょっと違うかなと思いました。

iCloud Documents

というわけで、iCloud Documentsを選択しました。
これはイメージ的にはPagesやNumbersのファイル保存と同じです。

調べてみると、意外と情報が出てきませんでした。
よく考えたら、普通モバイルアプリってiOS/Android両方出すので、iCloud DocumentsだとAppleの環境でしかアクセスできないので、あまり採用されない気がしますね。

公式ドキュメントはDesigning for Documents in iCloudなんですが、イマイチ要領がつかめず……サンプルコードが少ないせいかな。

そんな中、下記のQiita記事に助けられました。

iCloud Documentsを使ってファイルの読み書きをする

実装そのものよりも、Capabilityの追加→iCloudコンテナの作成→info.plistの編集がブラックボックスすぎて辛かったです。
Appleしか知らない設定情報なのに、アーカイブされたドキュメントしか公式の資料がないってどういうことやねん。

iCloud Driveのパスは、ローカルと同じように、FileManagerでとれます。

FileManager.default.url(forUbiquityContainerIdentifier: nil)

コード的にはiCloudに書いていることをさほど意識せずに書けるので、DX良いですね。

あとUIDocumentを継承しないと書けないっぽいです。
わたくしの実装としてはこちらを。
元々プロパティリスト形式のファイルを、UIDocumentでワンクッション置いていて、ちょっとまわりくどい気がしていますが、今のところはこれで。

ファイルが見えない?

上記のQiita記事に従って実装したのですが、書いたファイルがiOSのFilesアプリから見えない、という事象が発生しました。
info.plistNSUbiquitousContainerIsDocumentScopePublicをtrueにしていれば見えるはずだったんですが、これがどうしても見えませんでした。

色々試した結果、iCloudのコンテナ名をBundle IDにそろえたら表示されました。
Bundle IDがcom.example.MyAppだとしたら、iCloud.com.example.MyAppにして、コンテナをつくります。
そろえてないと、アプリとの対応関係を認識してくれないようです。
(書きこみ・読みこみ自体は可能)

デバイス上のDocuments直下のファイルが見えない問題

Filesアプリからファイルが見えない問題は、実はローカルでも同じことが起こっていて、気にはなっていました。
Documents/以下に書いたファイルはユーザーから見える、と書いてあるので、FIlesアプリから見られるんじゃないの? と思っていました。

どうやらinfo.plistに公開設定しないと、デフォルトだと非公開みたいです。

iOS 11ファイルAppにDocumentsフォルダを表示して他のアプリと共有する方法

iCloudが有効かどうかの判定

上記Qiita記事の通りにやれば、ファイルのwrite/readはできますが、ユーザーがiCloud Driveの利用をオフにしてると、パスがnilで返ってきます。

iCloudが有効かどうかの判定処理を書きましょう。

url(forUbiquityContainerIdentifier: nil)がnil返したら無効になっている、と判定しても良いのですが、ドキュメントを読むと、ubiquityIdentityTokenで見るのが推奨っぽいです。
僕は下記のようなコンピューテッドプロパティをつくりました。

private var isiCloudEnabled: Bool {
    (FileManager.default.ubiquityIdentityToken != nil)
}

更に真面目にやるなら、アプリの利用中にあるApple IDから別のApple IDへスイッチするケースが想定される(実際そんなことするユーザーがどんぐらいいるかはともかくとして)ので、
NSUbiquityIdentityDidChangeをNotificationCenterで監視する必要があります。
(僕のアプリの場合、スイッチされてもクリティカルなエラーにはならないはずなので、サボってます)

コンフリクトの解消

クラウド上にデータ保存するときにどうしてもネックになるのが、ネットワーク回線の問題です。
逆にデバイス上に書いていたときの最大のメリットは、回線に左右されないことでした。

当初は回線がないと、iCloudが使えないので、その間ローカルに別ファイル書かなきゃいけないのかと想像してましたが、
実際に使ってみると、iCloud Driveが有効だと、仮に回線が切れていても、ローカルのiCloud Driveファイルを更新して、
回線が復旧したタイミングで更新にいく、という挙動になって、よくできてるなあと思いました。

ただこの仕組み上、コンフリクトのリスクは生じます。下記の手順で100%起こせます。

  • デバイスAでノートを開いて、機内モードにして、編集
  • デバイスBでノートを開き、更新
  • デバイスAの機内モードを解除して、編集したノートを更新
  • デバイスBの更新が失われる!

きちんとドキュメントに明言されている箇所は発見できませんでしたが、デバッグしてみた結果、iCloudはどうやら後勝ちになるルールっぽいです。

Like Paperはそこまで厳密にコンフリクト管理したいアプリではないし、多端末からの同時編集はブロックまではしてませんが、保証はしていないので、後勝ちになっていれば十分かなと思いました。

ただファイル自体は後勝ちで更新されていくのですが、裏ではコンフリクトした敗者のバージョンは(Filesアプリからは見えませんが)生き残っていて、Statusもずっとコンフリクト状態が残ります。

真面目にやるんであれば、コンフリクトしたバージョン両方をユーザーに選ばせる、という選択肢もあるかとは思います。Evernoteがそうなっていましたが、個人的には結構ストレスでした。

Resolving Document Version Conflicts

↑コンフリクトに関するドキュメントはこれです。
放置しても、コンフリクトしたバックアップバージョンが溜まっていくだけなので、大きな問題ではないかとも思うのですが、
一番厄介なのはステータスが常にコンフリクト状態になるので、ドキュメントの更新をNotificationCenter経由で検知している部分が機能しなくなるところですね。
iCloudデフォルトの後勝ちルールに任せつつ、UIDocumentのコンフリクト状態は解消させてやる必要がありました。

    @objc private func reloadIfNeeded() {
        /*
         UIDocument.State
        .normal:            0b00000000 -> 0
        .closed:            0b00000001 -> 1
        .inConflict:        0b00000010 -> 2
        .savingError:       0b00000100 -> 4
        .editingDisabled:   0b00001000 -> 8
        .progressAvailable: 0b00010000 -> 16
         
        State is OptionSet. Rawvalue can be some combination.
        For example, .inConflict & .progressAvailable equales 20
         */
        print(documentManager.document.documentState.rawValue)
        if documentManager.document.documentState == .normal {
            reload()
        }
        if documentManager.document.documentState == .inConflict {
            documentManager.resolveConflict()
        }
    }
    // The winner choose by iCloud will be winner.
    // Maybe simply based on modificationDate. A later saved is a winner.
    func resolveConflict() {
        do {
            let currentVersion = NSFileVersion.currentVersionOfItem(at: saveURL)
            try NSFileVersion.removeOtherVersionsOfItem(at: saveURL)
            currentVersion?.isResolved = true
        } catch {
            print("failed delete conflict files")
        }
    }

コンフリクトの解消にはNSFileVersionを使います。

まとめ

以上、iCloud対応するときに調べたことをまとめました。
同じ課題に取り組もうとしている方の参考になったら幸いです。

Discussion