Androidアプリを丸ごとiOSに移植する — CKShareの壁と向き合った開発秘話

はじめに — Androidで作ったものを、もう一度iOSで作る

Android版「Lunch Log」をPlay Storeに公開してから、次にやりたいことは決まっていた。iPhoneでも同じアプリを使いたい人がいるはずだ、という単純な理由だった。ただし今回は「移植」と言っても、Androidのコードを機械的に書き写す作業にはしたくなかった。せっかく一から作り直すなら、iOSらしい設計で、しかもコストが一切かからない構成にしたい。そう決めて、最初にひとつだけ方針を立てた。「すべてApple標準APIで作る。外部APIの利用料は一切発生させない」。

Android版ではGoogle Maps SDKやGoogle Places APIなど、課金対象のAPIに頼っていた部分があった。iOSにはMapKit、MKLocalSearch、SwiftData、CloudKitという無料の代替がすべて揃っている。理屈の上では「Android版と同等、しかも無料」が実現できるはずだった。しかし実際にコードを書き始めると、理屈通りにいかない箇所がいくつも現れることになる。この記事は、その過程で遭遇した壁と、どう乗り越えたかの記録だ。


第1章 — CKShareの壁、設計をまるごと捨てた日

友達とランチ記録を共有する機能は、Android版でもユーザーに好評だった機能のひとつだ。iOS版でも当然実装するつもりで、最初に選んだ方式はCKShareだった。CloudKitの標準的な共有機能で、Appleの流儀に沿った「正しい」実装のはずだった。仕様書にもそう書いて、実装に着手した。

ところが調べていくうちに、SwiftDataがCKShareを公式にサポートしていないという事実にぶつかった。CKShareを使うにはカスタムゾーンでのレコード管理が必須だが、SwiftDataの自動CloudKit同期はデフォルトゾーンでしか動作しない。つまりSwiftDataを使う限り、CKShareという選択肢はそもそも存在しなかった。ドキュメントのどこにも「できません」とは書いていない。実装を試して、エラーメッセージと格闘して、ようやく「これは無理だ」と確信するまでに数日かかった。

ここで選択肢は二つだった。SwiftDataを諦めてCore Data + CloudKitに戻すか、CKShareを諦めて別の共有方式を考えるか。SwiftDataの開発体験を手放したくなかったので、後者を選んだ。たどり着いたのが「iCloud Driveファイル共有」方式だ。自分のランチ記録と行きたいリストをJSONに書き出し、UIDocument経由でiCloud Drive上のファイルとして保存する。それを標準の共有シートの「人を追加」機能で友達に送る。友達はDocumentPickerViewでファイルを選択して取り込む。Android版のGoogle Driveリンク共有に近い、ポーリング型の仕組みだ。

リアルタイム通知は失った。CKShareなら実現できたはずのプッシュ通知は、ポーリング型では原理的に不可能だ。それでも、動くものを選んだ。「理想の設計」より「実際に動く設計」を優先する判断を、開発の初期段階で迫られることになるとは思っていなかった。


第2章 — ハッシュタグを1回タップしたら全部消えた

設定画面でカスタムハッシュタグを管理する機能を実装したときのことだ。各ハッシュタグの行に削除ボタンを置いた。動作確認をしていると、奇妙な現象に気づいた。ひとつのハッシュタグを削除しようとタップすると、なぜか同じ行にある別のボタンまで一緒に反応してしまう。1回のタップで複数の処理が走っているような挙動だった。

再現条件を絞り込むのに時間がかかった。SwiftUIのFormの中に複数のButtonを横並びで置いていたのだが、そのどれにも.buttonStyle(.plain)を付けていなかった。Form内では、ボタンのスタイルを明示的に指定しないと、SwiftUIが行全体をタップ領域として扱ってしまうことがある。結果として、1つのボタンをタップしたつもりが、同じ行にある他のボタンのアクションまで巻き込んで発火していた。

修正自体は.buttonStyle(.plain)を各ボタンに追加するだけで済んだ。しかし真因にたどり着くまでが長かった。最初は状態管理のバグを疑い、@Stateの更新タイミングを何度も見直した。ログを仕込んで実行順序を追い、ようやく「これはロジックの問題ではなく、タップ領域の問題だ」と気づいた瞬間、拍子抜けするほど単純な原因だった。SwiftUIのFormは便利だが、複数のインタラクティブ要素を1行に並べるときは、この罠を覚えておかないといけない。


第3章 — シェアカードが白紙になる呪いと、5回の作り直し

ランチ記録を画像カードにしてSNSにシェアする機能は、機能候補の中でも特に楽しみにしていたものだった。ところが実装してみると、初回タップ時だけシェア画像が真っ白になるという不具合に見舞われた。2回目以降は問題なく生成される。1回目だけダメだ。この「初回だけ」という条件が、原因の特定を難しくした。

最初に採用したのはImageRendererだった。SwiftUIビューを画像に変換するiOS 16以降のAPIで、実装もシンプルだ。ところがImageRenderer.uiImagenilを返すことがあった。原因はSwiftDataのlazy loading環境で、まだ属性がフォールト解決されていないタイミングでレンダリングが走ってしまうことだった。ここでUIHostingController + UIGraphicsImageRendererに置き換えて、この問題は解消したように見えた。

ところが今度は別の形で「初回だけ白紙」現象がぶり返した。UIHostingControllerのビューがWindowに追加されていない状態でdrawHierarchyを呼ぶと、初回レンダリングが空になる。Key Windowを取得し、画面外(x: -400)に配置してからaddSubviewし、layoutIfNeeded()を呼び、Task.yield()で1サイクル待ってからdrawHierarchyを実行する——という手順を組んで、build 6として一度TestFlightに上げた。

それでも完全には直っていなかった。Task.yield()ではSwiftUIのCADisplayLinkによる描画サイクルに追いつけないタイミングがあったのだ。次に試したのはCATransaction.flush() + 200ミリ秒のスリープ。これでbuild 6を再アップロードした。ここまでで、同じバグに対して既に3回目の修正だった。

しばらくして、今度は発想を変えてImageRendererに出戻りすることにした。iOS 16以降のImageRendererはメインアクター上で同期的に描画するため、理屈の上では初回空白問題が起きないはずだ。ところが、これは第一の原因——SwiftDataのlazy loadingでnilが返る問題——にまた逆戻りするだけだった。ぐるっと一周して、出発点に戻ってきてしまった格好だ。

最終的に落ち着いたのは、UIHostingControllerを使いつつ、待機時間を1000ミリ秒まで思い切って伸ばし、確実にSwiftUIの描画が完了してからdrawHierarchyを呼ぶ方式だった。ユーザー体験として1秒の待ち時間は短くないので、生成中はツールバーの「…」アイコンをスピナーに変えて、処理中であることが伝わるようにした。build 10まで来て、ようやくこの「呪い」は解けた。ひとつのバグを直すのに5回の設計変更を重ねたのは、この開発の中でも一番苦労した記憶として残っている。


第4章 — 削除するとアプリが落ちる

ギャラリーや行きたいリストから記録を削除すると、アプリがクラッシュすることがあった。エラーメッセージはThis backing data was detached from a context without resolving attribute faults。SwiftDataのモデルインスタンスを直接context.delete()に渡していたのだが、hashtagsthumbnailDataなど遅延フォールトする属性が、CloudKit同期の通知タイミングのずれによって、削除直後にまだ参照されてしまうことが原因だった。

再現性が一定ではなかったのが厄介だった。CloudKitの同期通知が来るタイミングに依存するため、ローカルだけで確認しているとほぼ発生せず、iCloud同期が有効な状態で何度か操作を繰り返すと突然落ちる。原因を確定させるまでにログを何度も取り直した。

解決策は、インスタンス参照による削除をやめて、IDを指定したバッチ削除に切り替えることだった。context.delete(model:where:)を使えば、フォールト解決前のプロパティに触れることなく、IDだけで対象を特定して削除できる。LunchRepositoryWishlistRepositoryの両方で同様の修正を行い、以降このクラッシュは再発していない。


第5章 — xcodegenが黙ってバージョンを消していく

TestFlightにアップロードしたビルドを確認すると、何度上げてもバージョン表示が「1.0」のままになっていることに気づいた。project.ymlではちゃんと1.4と指定しているのに、生成されたInfo.plistを見るとCFBundleShortVersionString1.0CFBundleVersion1にハードコードされている。

原因はxcodegen generateを実行するたびにInfo.plistが再生成され、そのたびにデフォルト値で上書きされていたことだった。ビルド設定側のMARKETING_VERSION・CURRENT_PROJECT_VERSIONは正しく更新していたのに、生成されるInfo.plist側がそれを参照していなかった。手元では気づきにくいバグで、実際にTestFlightにアップロードして初めて発覚した。

修正はproject.ymlinfo.propertiesに、ビルド変数を明示的に埋め込むことだった。

info:
  properties:
    CFBundleShortVersionString: "$(MARKETING_VERSION)"
    CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"

これでxcodegenを何度実行してもバージョンがビルド設定を正しく参照するようになった。地味な修正だが、これに気づかないまま気づかず何度もリリースを重ねていたら、TestFlightのテスターに毎回「バージョン1.0」という表示を見せ続けることになっていた。


第6章 — カレンダーで1件だけの日が削除できない

カレンダー画面から日付をタップして記録を確認する機能で、地味だが致命的な不具合があった。ある日の記録が1件だけのとき、タップすると削除ボタンの無いEntryView(編集フォーム)に直接遷移してしまい、削除する手段がなかったのだ。2件以上ある日は一覧画面(DayDetailView)を経由するため問題なかったが、1件だけの日に限って削除できないという、条件分岐の隙間に落ちたバグだった。

修正は、既存のCalendarRoute.entryDetailルート(削除ボタン付きのEntryDetailView)を経由するように遷移先を変更するだけだった。1件でも複数件でも同じルートを通るようにすれば、削除ボタンの有無で挙動が変わることはなくなる。「1件のときだけ近道をする」という最適化のつもりの分岐が、機能の欠落を生んでいた典型例だった。


第7章 — 審査が1週間経っても終わらない夜

機能実装が一段落し、TestFlightの外部テストグループに申請を出した。Android版のPlay Store審査は数時間から数日で結果が来た記憶があったので、iOSも似たようなものだろうと軽く構えていた。ところが1週間経っても「審査中」のまま動きがない。

調べてみると、App Storeの審査期間は通常24〜48時間が目安とされているが、初回の外部テスト申請やアプリ内課金が絡む場合は2〜4日程度に伸びることがあるらしい。それでも1週間は明らかに長い。何かこちらの申請内容に不備があるのではと不安になり、Appleサポートに問い合わせる文面を用意することにした。結果を待つだけの時間は、バグを直している時間よりも精神的に堪えるものがあった。手を動かせない待ち時間というのは、開発者にとって一番落ち着かない時間かもしれない。


おわりに

Android版を開発したときは42件のバグと格闘したと以前書いた。iOS版はそれよりバグの絶対数こそ少ないが、ひとつひとつの根が深かった。CKShareの調査に費やした時間、シェアカードのバグを5回作り直した経緯、Form内のボタンが誤発火する謎——「動かない理由がわからない」時間の割合は、むしろAndroid版より長かったように思う。

それでも「すべてApple標準APIで作る」という最初の方針は最後まで貫けた。MapKit、SwiftData、CloudKit、StoreKit 2——どれも無料で、しかもAndroid版で使っていた課金APIと同等以上のことができた。方針を決めて実装を始めると、必ずどこかでその方針が試される場面が来る。CKShareが使えないとわかったときは、正直その方針を曲げようかとも思った。それでも曲げずに別の道を探したことが、結果的にはこのアプリの個性になったと思っている。

現在はTestFlightでのバグ修正・安定化を進めている段階で、近日中の正式公開を予定している。Android版に続いてiPhoneでもランチを記録できる日が来たら、両OSのユーザーで同じ体験を共有できるようになる。それを楽しみに、もうしばらく開発を続けていく。











この記事も含めて、自分はAIに指示しただけなんだけどね・・。


近日公開予定

ランチログ iOS版は現在TestFlightでのテスト配布段階です。App Storeでの正式公開に向けて準備を進めています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA