Objective-C - TECH PLAY - TECH PLAY

TECH PLAY

Objective-C

イベント

該当するコンテンツが見つかりませんでした

マガジン

該当するコンテンツが見つかりませんでした

技術ブログ

はじめに こんにちは。WEAR部iOSブロックの坂倉です。 WEARは先日、 SceneDelegate へ移行しました。ライフサイクルそのものに関わる変更のため、画面数の多い歴史あるアプリではなかなか骨が折れました。 この記事では、 WEARが直面した課題 、 課題を解決するために取ったアプローチ 、そして 移行中につまずいた箇所と対処 をご紹介します。 WEARのような大規模アプリが、影響範囲の広い SceneDelegate 対応をどのように進めたのか、その実例として参考になれば幸いです。 目次 はじめに 目次 SceneDelegate対応で直面した課題 iOS 27以降、未対応のアプリは起動できなくなる アプリのライフサイクルに関わる変更のため、すべての画面に影響する ライフサイクルイベントがAppDelegateとSceneDelegateに分かれて呼ばれる すべての画面で問題が出ないか、慎重に調査しなければならない 課題を解決するためのアプローチ アプローチ1. AppDelegateとSceneDelegateの役割を理解する AppDelegateの役割 SceneDelegateの役割 役割と違いのまとめ どの処理をSceneDelegateへ移すか プッシュ通知はAppDelegateに残す アプローチ2. AIを使って影響範囲を特定し、QA観点をまとめる 1. AIを使って影響範囲を把握する 2. AIが高い精度で仕事できるように、デリゲートメソッドを1個ずつSceneDelegateに移す 3. AIにQA観点を書き出させる これから対応するならXcode 27の併用も検討を アプローチ3. リスクを抑えるため、最小限の変更にとどめる つまずいた箇所と対処 一番最初のウィンドウを取得するのは危ない 外部ソースからの起動判定はconnectionOptionsで行う さいごに SceneDelegate対応で直面した課題 WEARが直面した課題は大きく2つあります。「対応しないという選択肢がない」ことと、「対応がアプリの全画面に影響する」ことです。 iOS 27以降、未対応のアプリは起動できなくなる Appleは公式ドキュメント Transitioning to the UIKit scene-based life cycle で、 UIScene ライフサイクルの採用が必須になると明記しています。最新SDKでビルドするアプリが対象となり、iOS 27・iPadOS 27・Mac Catalyst 27・tvOS 27・visionOS 27以降、未対応のアプリは起動できなくなります。 つまり、対応しない限り最新SDKでのアップデートを続けられなくなります。 アプリのライフサイクルに関わる変更のため、すべての画面に影響する SceneDelegate に対応すると、これまで AppDelegate が受け取っていたUI関連のライフサイクルイベントは SceneDelegate が受け取るようになります。ライフサイクルはアプリの根幹であり、この変更はWEARが持つすべての画面に影響し得ます。 具体的には次のような難しさがありました。 ライフサイクルイベントがAppDelegateとSceneDelegateに分かれて呼ばれる まず大変だったのが、これまで AppDelegate だけに届いていたライフサイクルイベントが、 AppDelegate と SceneDelegate の2つに分かれて呼ばれるようになる点です。 AppDelegate 中心の頃、起動時のイベントは次の順番で、すべて AppDelegate に届いていました。 application(_:willFinishLaunchingWithOptions:) application(_:didFinishLaunchingWithOptions:) applicationDidBecomeActive(_:) 初期化処理も1つのクラスに集まっているため、実行順序を追うのは難しくありませんでした。 ところが SceneDelegate を採用すると、アプリ起動時の実行順序は次のように2つのクラスをまたぐ形になります。 application(_:willFinishLaunchingWithOptions:) (AppDelegate) application(_:didFinishLaunchingWithOptions:) (AppDelegate) scene(_:willConnectTo:options:) (SceneDelegate) sceneWillEnterForeground(_:) (SceneDelegate) sceneDidBecomeActive(_:) (SceneDelegate) 起動処理が2つのクラスに分散すると、「この初期化はあの処理より先に終わっているはず」という前提が崩れます。たとえば、 SceneDelegate へ移したUIのセットアップが AppDelegate 側の初期化に依存していると破綻します。 さらに、呼ばれるタイミング自体が変わるイベントもあります。 sceneWillEnterForeground(_:) は applicationWillEnterForeground(_:) と違い、アプリ未起動(Not Running)からの起動でも呼ばれます。フォアグラウンド復帰時の処理をそのまま移すと、起動時にも実行されてしまいます。 どの処理を、どちらのクラスの、どのイベントに置くべきか 。アプリ全体の初期化処理を1つずつ見極める必要がありました。 参考: About the app launch sequence すべての画面で問題が出ないか、慎重に調査しなければならない WEARには通常起動のほかに、URLスキーム・ユニバーサルリンク・プッシュ通知など複数の起動導線もあります。特定の条件が揃ったときだけ顕在化するリグレッションもあり得るため、すべての画面について「移行しても問題が出ないか」を1つずつ調査していく必要がありました。 画面数の多いWEARにとって、これは人力だけでやり切れる規模ではありません。 課題を解決するためのアプローチ これらの課題に対して、WEARでは次の3つのアプローチで移行を進めました。 AppDelegateとSceneDelegateの役割を理解する AIを使って影響範囲を特定し、QA観点をまとめる リスクを抑えるため、最小限の変更にとどめる アプローチ1. AppDelegateとSceneDelegateの役割を理解する まず取り組んだのは、Appleの公式ドキュメントを読み込み、 AppDelegate と SceneDelegate がそれぞれ何を担当しているのかを正確に理解することでした。 AppDelegateの役割 UIApplicationDelegate プロトコルに準拠する AppDelegate は、アプリ全体のライフサイクルイベントを管理します。 アプリレベルのイベント管理 :アプリの起動・終了、システムレベルのイベントなど、アプリプロセス全体のライフサイクルを担当する。 application(_:didFinishLaunchingWithOptions:) などを通じて、アプリの初期設定や起動準備をする 初期の環境設定 :アプリ全体のデータやリソースの初期設定、プッシュ通知の処理などを担当する シングルトン :1つのアプリにつき、 UIApplication は1つしか存在しない 注意したいのは、 SceneDelegate に対応すると、UIに関するライフサイクルイベントは SceneDelegate の担当に移り、 AppDelegate には届かなくなるという点です。 SceneDelegateの役割 UIWindowSceneDelegate プロトコルに準拠する SceneDelegate は、「シーン」のライフサイクルイベントに応答します。シーンとは画面に表示されるアプリのUIのひとまとまりのことで、たとえばiPadで同じアプリを2つのウィンドウで並べて開くと、それぞれが独立したシーンになります。 シーンの管理 :アプリのUIを「シーン」として表現し、それぞれのシーンが独自のライフサイクルと状態を持てる。これにより、複数のウィンドウを並べて表示するマルチウィンドウ環境が可能になる シーン固有のライフサイクルイベント :これまで AppDelegate が扱っていたUI関連のライフサイクルイベントを SceneDelegate が担当する 役割と違いのまとめ 特徴 AppDelegate SceneDelegate 責任範囲 アプリ全体のプロセスとライフサイクル(起動・終了・システムレベルイベント)を管理 シーンのライフサイクルを管理 UIの管理 従来はアプリで唯一のUIとウィンドウを管理。iOS 13以降はUI管理の責任が軽減された 各シーンのUIのセットアップ・ウィンドウ管理・UIライフサイクルイベントを処理 マルチウィンドウサポート 単一のウィンドウのみ 複数のウィンドウをサポート 要するに、 AppDelegate はアプリの「プロセス」レベルの管理を担い続けます。一方の SceneDelegate は、シーンごとのUIの管理と、ライフサイクルイベントへの応答を担当します。 どの処理をSceneDelegateへ移すか 役割が分かれば、対応方針は明確です。 AppDelegate で処理していた次の処理を SceneDelegate に移行しました。 説明 移行前(AppDelegate) 移行後(SceneDelegate) 具体例 アプリ起動時のWindow生成処理 application(_:didFinishLaunchingWithOptions:) scene(_:willConnectTo:options:) Windowの生成、起動に必要なデータ取得、GA送信のための設定、URLスキームの受け取り シーンがアクティブになったときの処理 applicationDidBecomeActive(_:) sceneDidBecomeActive(_:) プッシュ通知バッジのリセット シーンが非アクティブになる直前の処理 applicationWillResignActive(_:) sceneWillResignActive(_:) — シーンがフォアグラウンドに入る時の処理 applicationWillEnterForeground(_:) sceneWillEnterForeground(_:) 必要に応じたリロードとAPI実行 シーンがバックグラウンドに入る時の処理 applicationDidEnterBackground(_:) sceneDidEnterBackground(_:) バックグラウンド移行時のUI後処理 ユニバーサルリンク処理 application(_:continue:restorationHandler:) scene(_:continue:) ユニバーサルリンクの受け取り URLスキーム処理 application(_:open:options:) scene(_:openURLContexts:) URLスキームの受け取り プッシュ通知はAppDelegateに残す ここで1つ注意点です。 プッシュ通知はアプリ全体に関わるイベントなので、引き続き AppDelegate で処理します 。 Appleのドキュメント にもある通り、デバイストークンの登録や通知の受信といったプッシュ通知のハンドリングには、 SceneDelegate 側に対応する移行先が用意されていません。 なお、通知への応答がシーンと無関係というわけではありません。通知へのユーザーの応答(タップなどの操作)をシステムがどのシーンに反映するかを示す UNNotificationResponse.targetScene というプロパティが用意されています。通知タップ後の処理でシーンが必要な場合は、このプロパティから取得できます。 アプローチ2. AIを使って影響範囲を特定し、QA観点をまとめる 13年の歴史を持ち、300個ものViewControllerを抱えるWEARにとって、影響範囲の調査は人力だけでやり切れる規模ではありません。そこで、Claude CodeやGitHub CopilotなどのAIをフル活用し、3つのステップを踏んで対応を進めました。 AIを使って影響範囲を把握する AIが高い精度で仕事できるように、デリゲートメソッドを1個ずつSceneDelegateに移す AIにQA観点を書き出させる 1. AIを使って影響範囲を把握する 先ほど触れた どの処理をSceneDelegateへ移すか の表をAIに読み込ませ、影響範囲を特定しました。また、具体的にどんな処理が入っているのかも表にさせたことで、必要な対応の解像度を上げました。 2. AIが高い精度で仕事できるように、デリゲートメソッドを1個ずつSceneDelegateに移す どの処理をSceneDelegateへ移すか で洗い出した対象メソッドは、もれなくSceneDelegateへ移す必要があります。しかし、一度にやってしまうと対応漏れが出かねません。AIの出力の質やスピードを上げる意味でも、範囲を絞って1個ずつ移すのが良いと判断しました。 3. AIにQA観点を書き出させる ここが一番助かった部分です。今まで、QAに提出する観点は人の手で書いていました。これを機に、その大半をAIが書く形へ変えました。 具体的な手順は次のとおりです。 まず、AIにSceneDelegate対応をさせてプロトタイプを作り、それをベースに全体の影響範囲とQA観点をざっと把握する メソッド移行ごとにブランチを切り、PR作成時、Diffの内容からJiraへQA観点を出力する プロトタイプで書き出したQA観点とブランチごとのQA観点を見比べて不足がないか確認する こうすることで、人間が気づかなかった範囲も調べて出力してくれるので、質の良いQAにつながりました。 具体的には、特定の地域からアクセスしたときだけ表示する利用規約Windowや、メンテナンス画面の表示・非表示のように複数のライフサイクルイベントにまたがって初めて成立する処理などが対象でした。普段意識する機会が少ない処理ほど、人力でQA観点を洗い出す際には抜け落ちがちです。AIに列挙させたことで、こうした処理も取りこぼさずQA観点に反映できました。 これから対応するならXcode 27の併用も検討を WEARのSceneDelegate対応は2025年に行ったため、Xcode 27を使うことはできませんでした。これから対応する場合は、Xcode 27の併用も検討してみてください。 WWDC26のセッション「 Modernize your UIKit app 」では、Xcode 27同梱の「uikit-app-modernization」スキルが紹介されています。このスキルはSceneDelegate対応だけでなく、UIScreen.mainやinterfaceOrientationのモダナイズなどにも対応しています。今進めているAdaptiveレイアウト対応でも役立っています。 また、Xcode 27は、Appleの公式ドキュメントやテックノートはもちろんのこと、Apple Developer Forumsの情報も参照してくれるため、非常に便利です。正式リリースが待ち遠しいですね。 アプローチ3. リスクを抑えるため、最小限の変更にとどめる 影響範囲の広さに対するもう1つの答えが、移行のスコープを必要最低限にとどめることです。WEARでは以下の方針で移行を進めました。 シーンのライフサイクルにのみ対応する 処理の移行を最優先し、処理のリファクタリングは後で行う マルチウィンドウの対応は一旦見送る SceneDelegate に対応すれば、iPadでのマルチウィンドウなど、できることは増えます。しかし、リスクを最小限に抑えるため、まずシーンのライフサイクル対応だけを完結させると決めました。 また、WEARの AppDelegate はObjective-Cで書かれた非常に古いコードだったため、本音を言えばリファクタリングもしたいところでした。今回、 SceneDelegate へ移行した処理に限りSwiftへの置き換えを行いましたが、処理そのものには手を加えず、挙動を変えない必要最小限の修正にとどめています。 つまずいた箇所と対処 最後に、移行中に直面した代表的な不具合と、その対処を紹介します。 一番最初のウィンドウを取得するのは危ない SceneDelegate 対応後は、 UIWindow が各シーン( UIWindowScene )に紐づき、シーンごとの持ち物へと変わります。 本来は良くない実装ですが、WEARはこれまでSceneDelegateに対応していなかったため、以下のコードでWindowを取得しても問題になっていませんでした。 extension UIApplication { var firstKeyWindow : UIWindow? { let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene return windowScene?.windows.first( where : \.isKeyWindow) } } しかし、SceneDelegate移行後はマルチウィンドウが可能になるため、最初に取得したシーンのWindowが、いま操作しているWindowとは限らなくなります。 よって、ViewControllerからWindowを取得する場合は、 view.window を使って「いま表示しているシーンのWindow」を取得する形へ修正しました。ただし view.window は、Viewがウィンドウ階層に追加されるまでは nil のままなので、呼び出すタイミングには注意が必要です。 // Before: アプリ全体のkeyWindowに依存していた let rootVC = UIApplication.shared.firstKeyWindow?.rootViewController as? RootViewController // After: 自分が表示されているシーンのWindowから取得する let rootVC = view.window?.rootViewController as? RootViewController 外部ソースからの起動判定はconnectionOptionsで行う これまでは AppDelegate の launchOptions のキーで「外部から開かれた起動か」を判定し、ダイアログ表示などを制御していました。 SceneDelegate では、この起動情報は scene(_:willConnectTo:options:) に渡される connectionOptions へ集約されています。型は UIScene.ConnectionOptions です。 これを参照することで、URLスキームやユニバーサルリンク、プッシュ通知など、「どこで起動されたのか」を判別できます。 WEARでは次のように判定しています。 userActivities や urlContexts からディープリンクを取り出して種別を判定します。それらがなくリモート通知での起動であれば .remoteNotification を、いずれにも当てはまらない通常起動なら nil を返します。 extension SceneDelegate { enum ExternalSource { case urlScheme case universalLink case remoteNotification } } extension SceneDelegate.ExternalSource { @MainActor init? (_ connectionOptions : UIScene.ConnectionOptions ) { let userActivityURL = connectionOptions.userActivities.first { $0 .activityType == NSUserActivityTypeBrowsingWeb }?.webpageURL let urlSchemeURL = connectionOptions.urlContexts.first?.url if let deepLinkURL = userActivityURL ?? urlSchemeURL { switch deepLinkURL.scheme { case "URLスキーム名" : self = .urlScheme case "https" : self = .universalLink default : return nil } return } if connectionOptions.notificationResponse != nil { self = .remoteNotification return } return nil } } func scene (_ scene : UIScene , willConnectTo session : UISceneSession , options connectionOptions : UIScene.ConnectionOptions ) { let externalSource = SceneDelegate.ExternalSource(connectionOptions) } さいごに 「技術調査で役割を正確に理解する」「AIで影響範囲を特定する」「最小限の変更にとどめる」という3つのアプローチにより、 SceneDelegate 対応を無事完了できました。 なかなか骨が折れましたが、 SceneDelegate に対応したことでマルチウィンドウに対応する土台ができ、AIエージェントを使って大規模な修正に取り組む経験も得られました。 この記事が、 SceneDelegate 対応を控えている方の一助になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
Web未経験MLエンジニアが社内プロダクト開発でAIコーディングにどハマりするまで はじめに こんに ...
はじめに こんにちは、ZOZOTOWN開発1部iOSブロックの荻野です( @juginon )。 みなさんに日々使っていただいているZOZOTOWN iOSアプリのホーム画面ですが、実は2024年秋から2026年の年初まで約1年半、水面下でリアーキテクチャを行っていました。 リアーキテクチャに着手する前の当時の私はアーキテクチャ設計への理解がまだ浅く、「実際に手を動かしながら身につけたい」という動機でこのリアーキテクチャを主導しました。自分にとってはチャレンジングな取り組みで、アーキテクチャ設計やテスト設計への理解が実践を通して大きく深まったプロジェクトになりました。 本記事では、そのリアーキテクチャのすべての軌跡と、そこで得た学びをお伝えします。 なお、本記事で紹介するホーム画面リファクタリングは、iOSチーム全体で取り組んでいるアーキテクチャ刷新の具体的な事例の1つでもあります。チームとしての取り組みや知識共有の仕組みについては ZOZOTOWNのiOSアーキテクチャとチーム進化の軌跡 にもまとめています。本記事と合わせて読むと、個々の取り組みとチーム全体の文脈をより立体的に理解できます。 目次 はじめに 目次 ホーム画面について タブ モジュール ホーム画面が抱えていた課題 当初の設計と、その後の運用実態の乖離 継承構造が不要になった ログ管理の複雑化 MVCによるViewControllerへの責務集中 高い改修頻度 リファクタリングの設計方針 方針1: 影響範囲を最小化しながら段階的に進める 方針2: 段階的に責務を分離する Step1: Objective-Cレガシー型への依存を剥がす Step2: 最も独立性の高いAPIを対象に、ViewModel/UseCaseを部分導入する 小さく始めることの重要性 Step3: MallHomeViewController全体にViewModel/UseCaseを導入する 問題1. Swift Concurrencyへの移行 問題2. モジュール構築メソッドの整理 Step3完了後にログに関するバグが発覚 バグを引き起こした原因 Step3-ex: 命名整理とユニットテストの追加 命名の整理 ユニットテストの追加 不確かさに気づいた時点でテストを書く Step4: HomeViewControllerにViewModel/UseCaseを導入する TDDによる設計の共有 オンボーディング周りの状態管理 リファクタリング前の課題 ステートマシンによる再設計 長期リファクタリングを進める上でのポイント おわりに ホーム画面について ZOZOTOWN iOSアプリのホーム画面は以下のように、主にタブとモジュールによって構成されています。 タブ 画面上部に表示されている「すべて」「コスメ」部分を指します。タブは切り替えが可能で、すべてタブではアパレル・シューズ・コスメ等すべての商品が、コスメタブではコスメ商品特化の画面表示になります。 実装上は以下の2種類のViewControllerで構成されています。 HomeViewController : ホームタブのルート画面となる画面全体を管理するViewController ヘッダーや検索窓など、両方のタブで共通して表示する部分、ホーム画面全体の管理を担う MallHomeViewController : すべてタブ/コスメタブのコンテンツを管理するViewController それぞれのタブで表示が変わる部分の管理を担う モジュール 各タブのコンテンツは、複数の「モジュール」と呼ばれるブロックが縦に並んだ構成です。モジュールとは、性別選択・バナー・チェックしたアイテムといった、個々のコンテンツ単位のことです。 ユーザーがホーム画面をスクロールすると、これらのモジュールが順番に表示されます。 ホーム画面が抱えていた課題 当初の設計と、その後の運用実態の乖離 ホーム画面の複雑さを理解するには、2021年のフルリニューアル時の背景を知る必要があります。 2021年3月のZOZOTOWNフルリニューアルで初めてタブ構成が導入されました。当時は3つのタブがあり、 MallHomeViewController を基底クラスとした3つのサブクラスによる継承構成を採用しました。各タブで固有の処理が発生することを見越した設計です。 当時の取り組みについては ZOZOTOWNアプリ Home画面のリニューアルにおけるアーキテクチャ再設計 でも詳しく紹介されています。 しかし、フルリニューアルから3年以上が経過し、運用を重ねる中で当初の設計前提が変わっていきました。 継承構造が不要になった 従来では MallHomeViewController を継承する各タブのクラスを作成していましたが、各タブで固有の処理は実際にはほとんど発生しませんでした。 タブの種類を保持するだけで十分な状態で、各タブで専用のクラスを作成する構造はかえって全体像の把握を難しくしていました。 ログ管理の複雑化 リニューアル当初はGA(Google Analytics)のみだったログ送信を専用のLoggerクラスが管理していました。しかしその後、社内分析用ログなど複数種別のログが追加されていく中で、Logger自身が複雑な状態管理を担うようになっていきました。 複数のフラグがLoggerの内部に積み重なり、 MallHomeViewController が持つ状態と常に同期させる必要が生じました。また、ログに関する責務分離が適切に行われていない部分もあり、こういった構造がコードを読む際のコストを高める要因の1つになっていきました。 MVCによるViewControllerへの責務集中 2021年当時はMVCアーキテクチャを採用していたため、API呼び出し・UI状態管理・ビジネスロジックの調整が MallHomeViewController に集中していました。前述のLoggerクラスとの状態同期もVCが直接担っており、改修を加えるたびにVC・Logger双方への影響を考慮しなければなりませんでした。こうした積み重ねで行数は再び1000行を超えるまでに膨らんでしまっていました。 特に問題だったのは、UICollectionViewへのデータ構築と商品押下時のログデータ作成が混在する500行弱の巨大なメソッドです。どこを触れば何が変わるのか把握するだけで大きなコストがかかる状態でした。 高い改修頻度 ZOZOTOWNのホーム画面は平均月1ペースで改修案件が入り、多い時期には3案件が同時並行で走ることもあります。 このリアーキテクチャが開始してから現在まででも、ホーム画面のモジュールを無限スクロールできる機能や、モジュール内のアイテムで動画を表示する機能など、規模の大きな案件がリリースされています。 影響範囲の把握が困難なFat ViewControllerは、改修のたびにリスクを伴い、チームの開発速度を下げる原因になっていました。 リファクタリングの設計方針 課題は明確でしたが、1000行超のVCを一気に書き換えるのはリスクが高すぎます。そこで以下の方針を立てました。 なお、このリファクタリングは通常の機能開発と並行して進めており、稼働の約2割をこの取り組みに充てながら進めていました。1年半という期間はそのためです。 方針1: 影響範囲を最小化しながら段階的に進める 各ステップの影響範囲を小さく保つことで、問題発生時の修正コストを抑えられ、PRの変更量も少なくなりレビューの負担を減らせます。また各ステップを独立してリリース可能な単位とすることで、他案件の進行をブロックしません。 以上のメリットを意識しながら以下のステップで進める計画を立てました(当初は4ステップ、結果として5ステップになりました)。 ステップ 内容 Step1 Objective-Cレガシー型への依存を剥がす Step2 最も独立性の高いAPIを対象に、ViewModel/UseCaseを部分導入する Step3 MallHomeViewController全体にViewModel/UseCaseを導入する Step3-ex Step3完了後にバグが発覚し、命名整理とユニットテストを追加 Step4 HomeViewControllerにViewModel/UseCaseを導入する  ステップを設計する上でのポイントを3点紹介します。 Step1を最初に行った理由 MallHomeViewController にはObjective-Cのレガシーな型への依存がありました。MVVM化を先に進めると、ViewModel/UseCaseはObjCの型を扱う設計になります。その後ObjC依存を除去すると、ViewModel/UseCaseの設計変更も必要になり手戻りが発生します。そのため、MVVM化の前段階として依存の除去を最初のステップとしました。 MallHomeViewControllerから先に着手した理由 タブの中身を管理している MallHomeViewController は、着手開始から間もなく後続案件の改修が入る予定でした。そのため、それより前にMVVM化を完遂させることを優先しました。 Step2とStep3を分けた理由 ホーム画面では複数のAPIを呼び出しており、最初から全APIを対象とするとMVVM化の影響範囲が大きくなりすぎます。まず独立性の高い一部のAPIに絞ってViewModel/UseCaseを導入することで、アーキテクチャの全体像を小さな変更で確認でき、問題が発生した際の修正コストも抑えられます。 方針2: 段階的に責務を分離する UseCase → ViewModel → ViewControllerの順で責務を分離していき、最終的に以下の構成を目指しました。当時のアーキテクチャガイドラインではUseCaseの採用が定められていました。またAPIリクエスト・ログ送信・ビジネスロジックが複合的に絡むホーム画面の規模感においても、ViewModelの肥大化を防ぐうえで適切な設計判断でした。 ここで紹介している大まかな全体方針は、以前チームメンバーの なんしー さんが行った 商品詳細画面のリアーキテクチャにおける進め方 を参考にしています。 Step1: Objective-Cレガシー型への依存を剥がす MallHomeViewController では、商品情報を表示する部分がObjective-Cで書かれたレガシーな型に依存しており、APIレスポンスからレガシーな型へ変換する不要な依存がありました。そのため、最初のステップはMVVM化でなく 不要な依存の除去 から始めました。 以下の3段階で依存を剥がしました。 商品の情報表示において必要な情報を持つUIModelを作成 APIレスポンスをそのUIModelに変換するTranslatorを作成 Translatorは外部APIのレスポンス型をUIModelの型に変換する責務を持つ 外部APIの型定義が変更されてもViewModelやVCへ直接影響しない構造になる レガシーな型を使わない新しいセルを実装し、移行 最終的に MallHomeViewController からObjective-Cレガシー型への依存を完全に除去しました。 Step2: 最も独立性の高いAPIを対象に、ViewModel/UseCaseを部分導入する Step1でクリーンな基盤ができたため、いよいよMVVM化に着手します。設計計画で「最も独立性が高い」と判断した 世代別ランキングモジュール から始めました。 世代別ランキングモジュールとは、ユーザーが世代(~10代、20代など)を選択すると、その世代の人気アイテムがランキング形式で表示されるモジュールです。 ヘッダーの世代選択ボタンをタップして切り替えると、対応するランキングが再取得・再表示されます。 以下の特徴があったため、ホーム画面のMVVM化における最初のステップとして工数がかからず、アーキテクチャの全体像を実装しながら理解できる最適な題材と判断し、着手しました。 世代別ランキング専用の独立したAPIを持つ ユーザーが世代を選択したときだけ更新される 他のモジュールの更新と独立して動作する 小さく始めることの重要性 Step2は全部で7つのPRを作成しました。UseCase作成→UIModel作成→ViewModel作成→ViewControllerからUseCase/ViewModelへ処理を移動する流れで修正を加えていきました。 巨大なViewControllerを一気に書き換えようとすると、変更が大きくなりすぎてレビューが困難になり、バグ混入リスクも高まります。Step2でOpenした7つのPRのほとんどが100行未満のコード追加に収まっており、レビューでの指摘もほとんどなくスムーズにマージできました。 また、Step2を通して PRの分割方法 や 変更を加えるレイヤーの順番 が明確になり、次のステップであるモジュール更新全体のリアーキテクチャへの自信がつきました。大規模なリファクタリングに着手する際は、最も独立性の高い部分から始めることで、レビューでの問題検知やバグ混入の防止に直結します。最初の小さなステップを通じてPRの分割方法や変更を加えるレイヤーの順番を把握しておくと、後続の大きなステップをより自信を持って進められます。 Step3: MallHomeViewController全体にViewModel/UseCaseを導入する ホーム画面では、世代別ランキングモジュールの取得API以外に合計4つのAPIを並行して呼び出しています。Step3ではそれらの主要APIを呼び出している部分すべてにViewModel/UseCaseを導入しました。Step3はStep2のようにスムーズには行かず、いくつかの問題に直面しました。代表的な問題を紹介します。 問題1. Swift Concurrencyへの移行 当時の MallHomeViewController では、一部分のAPI呼び出しに BrightFutures を使っていました。このライブラリは2022年にEOLとなっており、チーム内でも新規実装では非推奨としていたため、このタイミングでSwift Concurrencyへ移行しました。 Swift Concurrency対応に関してもこのときが初めての経験で、その中で色々と学びがありました。 並行処理によるビュー表示時の表示順担保 クロージャベースのコードでは、複数のモジュール取得APIを直列で呼び出しており、すべてのレスポンスが揃ってから一括で描画していました。Swift Concurrencyへ移行して並行呼び出しにしたことで、どのAPIレスポンスが先に返ってくるかが不定になります。レスポンスを受け取った順にUIModelを積んでいく実装のままでは表示順が変わってしまいますが、実装当初はこの問題に気づいていませんでした。 UIModelの配列に常に決まった順序で格納する実装に修正することで解決しました。すべてのAPIレスポンスが揃ってから正しい順序でまとめて描画するという基本的な流れは変わらず、並行取得による速度改善と表示順の保証を両立しています。 withCheckedThrowingContinuation にキャンセルが伝播しなかった 特定のAPI呼び出しにはタイムアウト処理が必要でした。 withThrowingTaskGroup を使い、 データ取得タスク と 一定時間後にタイムアウトエラーを投げるタスク を並走させました。どちらかが完了したら group.cancelAll() でもう一方をキャンセルする実装を採用していました。 しかし実際にはキャンセルが正しく機能していませんでした。通信が切断された状態でリロードを繰り返すと、タイムアウトが発生して group.cancelAll() が呼び出されているにもかかわらず、ローディングが永遠に続く不具合が発生していました。 原因は、コールバック型のサードパーティSDKを withCheckedThrowingContinuation でブリッジしていた部分にありました。このSDKは通信切断時にコールバックを呼び出さない場合があります。タスクグループのキャンセルは withCheckedThrowingContinuation 内には自動で伝播しません。コールバックが呼ばれない限り、continuationは解決されないままとなります。 // 修正前: キャンセルが continuation に伝播しない func fetchData () async throws -> Response { try await withCheckedThrowingContinuation { continuation in legacySDK.fetch { result in // 通信切断時はここが呼ばれない場合がある // group.cancelAll() されても continuation は resolve されないまま continuation.resume(with : result ) } } } // 修正後: withTaskCancellationHandler を追加し、キャンセル時に continuation を resolve する func fetchData () async throws -> Response { let holder = ContinuationHolder() return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in holder.continuation = continuation legacySDK.fetch { result in holder.continuation?.resume(with : result ) } } } onCancel : { // タスクがキャンセルされたとき、onCancel でエラーを投げて continuation を解決する holder.continuation?.resume(throwing : APIError.cancelled ) } } 対応方法は withTaskCancellationHandler を追加することでした。タスクがキャンセルされると onCancel クロージャが呼ばれ、そこでcontinuationにエラーを投げることで、コールバックが返ってこない状態でもタスクを終了できます。continuationへの参照を class で保持しているのは、 onCancel クロージャが別コンテキストで実行されるためです。 var ではSwift Concurrencyの警告が出ます。 withCheckedThrowingContinuation はコールバック型APIを async/await に変換する手段として有効ですが、タスクキャンセルは自動では伝播しません。キャンセルに対応させるには withTaskCancellationHandler と組み合わせて、 onCancel 時に明示的にcontinuationを解決する必要があります。 問題2. モジュール構築メソッドの整理 Step3の終盤では、ViewControllerに置かれていた500行弱の巨大なモジュール構築メソッドを整理しました。 このメソッドには2つの責務が混在していました。 UICollectionViewに表示するデータソースの作成(VC側の責務) 商品押下時のログ送信に必要なモジュール内位置情報の計算(VM側の責務) 後者をViewModelへ移動し、各モジュールの同一性比較を可能な構造とすることで、位置情報を適切に取得できるようにしました。 やること自体は一文で書けるようにとてもシンプルなものです。実装当時の自分の認識も同様で、この整理に関してはスムーズに進み、そのままStep3をリリースしました。 しかし、ここで今回のリアーキテクチャにおける最大の壁にぶつかってしまいます。 Step3完了後にログに関するバグが発覚 Step3のリリース後、モジュールを管理しているチームから「カルーセルバナーのタップログで、バナーの位置が正常に送られていない」という問い合わせが届きました。 調査の結果、カルーセルバナーのタップ時のログに含まれる「バナーの位置」として、 ホーム画面全体におけるセクションの表示位置 を誤って送信していたことが判明しました。本来送るべき値は カルーセル内のバナーの位置(何枚目のバナーか) でした。 バグを引き起こした原因 モジュール/セクション/インデックスなどの位置に関する命名の曖昧さ ホーム画面は複数のコンテンツを縦に並べた構成です。「画面上の表示順(セクション位置)」と「各コンテンツ内の位置(インデックス)」という2種類の"位置"が存在しますが、コード上でこれらを区別する命名が不明確でした。 APIから取得したレスポンス名/ログ送信用パラメーター名/内部で使用している変数名のそれぞれの使い分けが曖昧なまま実装を積み重ねており、コードを読む際に混同しやすい状態でした。 ログの値の正しさをテストで検証できていなかった 「ログが送信されること」は手動確認で検証していましたが、「送信されたログの値の正しさ」まで検証できていませんでした。 当時はユニットテストが整備されていなかったため、コードレビューだけでは防ぎきれませんでした。ユニットテストがあれば、このバグはリリース前に検知できたはずです。 これらを検知できなかった背景として、Swift Concurrency対応での想定外の工数による焦りと、ログの重要度を甘く見積もっていたことが挙げられます。 Step3の終盤のPRはStep2とは打って変わって500行を超える大きなPRになってしまい、レビュアにも大きな負担をかけてしまいました。「小さく分割して進める」という当初の方針を貫けなかった点も反省の1つです。 Step3-ex: 命名整理とユニットテストの追加 バグを迅速に修正した後、Step3の延長として命名の整理とユニットテストを追加しました。 命名の整理 Step3でのバグ原因の1つが「ログ送信コードの読みにくさ」にあったため、まず命名を整理してからテストを書くという順序を選びました。 モジュール・セクション命名の統一 UICollectionView上の概念の呼び方と変数の型を整理し、「モジュール」と「セクション」の使い分けルールを明確にしました。 ログ送信の位置情報に関する命名統一 セクションの表示位置とセクション内の商品位置を表す変数名を、それぞれ明確に区別できる名前に統一しました。 ユニットテストの追加 バグを引き起こしてしまったログ送信時のセクション位置に関するテストをはじめとして、モジュールの取得、性別変更、画面遷移、ライフサイクルイベントなど多数のシナリオをカバーしました。Step2, Step3でUseCaseをプロトコルでDIできる構造になっていたため、Mockを使ったテストが書けるようになっています。 ユニットテストを新規で書いていくのも初めての経験だったため、テストに関する知識が豊富なチームメンバーにモブレビューを行ってもらいました。 命名整理とテスト追加を終えた時点で、MallHomeViewModelのテストカバレッジは38%から99%に向上しました。 不確かさに気づいた時点でテストを書く Step3では「アーキテクチャを整備してからテストを書けばいい」という考えからバグを引き起こしてしまい、その考えの危うさを実感しました。バグや不確かさに気づいたタイミングでテストを書くことで、結果的に次のステップを安心して進める力になります。 Step4: HomeViewControllerにViewModel/UseCaseを導入する 最終ステップのStep4ではホーム画面全体を管理している HomeViewController のリアーキテクチャを行いました。このステップでは、Step3までの失敗と学びを活かして TDD(テスト駆動開発) を採用しました。また、Step3でのPR分割の粒度ミスを踏まえ、レビューしやすい粒度でPRを作成しレビュアへの負担も考慮したPR戦略を取りました。 TDDによる設計の共有 Step4で特筆すべきは、 UseCase/ViewModelのテストケースをProtocol/実装より先に作成した ことです。UseCase/ViewModelのテスト雛形作成 → テストケースの作成 → UseCase/ViewModelとProtocolの作成 → 実装、という順番で進めました。 このTDDアプローチが特に威力を発揮したのが、 ログ送信周りの仕様整理 でした。 HomeViewController のログ送信ロジックは複雑で、起動経路(通常起動・プッシュ通知・Deeplink)やタブ切り替えに応じてどのログをどのタイミングで送るかが変わります。また、同じ画面遷移でも複数のライフサイクルイベントが連続して発火するため、ログの二重送信を防止する制御も必要です。このような仕様では実装者ごとに解釈が分かれやすく、Step3と同じ轍を踏む可能性もありました。 そこで実装に先立ち、起動経路ごとのログ送信フローをドキュメントとして整理し、 チームで仕様を合意した上でテストケースを設計する というプロセスを踏みました。ドキュメントには どの動線でどのログが何回送られるべきか を網羅的に記述し、それをそのままテストの仕様として共有しました。 テスト設計において重要な方針として、 内部のフラグ状態ではなくユーザーの動線単位でテストを記述する ことを採用しました。例えば以下のようなシナリオをそのままテスト名として記述しています。 通常のアプリ起動でホーム画面を表示したとき、ログが1度だけ送信されること プッシュ通知でアプリを起動したとき、特定のログは送信しないこと Deeplinkでホーム画面に遷移したとき、viewWillAppearでのログ送信はスキップすること 「どの動線で何が起きるべきか」という形でテストを書くことで、テストが仕様書として機能するようになります。内部実装がリファクタリングで変わっても、動線ベースのテストはそのまま維持できるため、保守性も高まりました。 テストを先に書くことで、「このUseCaseは何をすべきか」をチームで議論しながら設計を進めることができました。Step3でロジックの漏れがバグにつながったという反省が、ここで活きています。 オンボーディング周りの状態管理 HomeViewController は オンボーディング (初回起動時の案内フロー)周りの状態管理も複雑です。 リファクタリング前の課題 初めてZOZOTOWNアプリをインストールしたユーザーは、ホーム画面が表示されるまでに複数の案内画面を経由します。 問題は、この一連のフローを管理するために 5つ以上のBoolフラグ が複数のファイルにまたがって散在していたことでした。例えば「ログイン画面の表示が完了したか」「プッシュ通知許諾を表示したか」「訴求バナーの表示が必要か」といったフラグが各所に分散していました。それらを組み合わせた条件分岐によって次の表示内容が決まる構造になっていました。これにより、「今どのフラグがどの状態のとき何が起きるのか」を把握するだけでもかなりのコストがかかっていました。 このような複雑さが原因の1つとなり、オンボーディングに関する不具合が発生したこともありました。 ステートマシンによる再設計 Step4ではこのオンボーディングフローをステートマシンとして再設計しました。 オンボーディングは以下の4つの状態(State)と、それぞれを遷移させるイベント(Event)によってモデル化されます。 ViewModelはこの状態を購読し、状態に応じてどの画面を表示するかを宣言的に記述します。 この設計により、「現在のフローのどこにいるか」が状態として一点に集約され、遷移のトリガーとなるイベントも明示的になりました。それまでのフラグの組み合わせによる暗黙的な状態管理から脱却し、コードを読むだけでオンボーディングフローの全体像が把握できるようになりました。 また、「どのイベントでどの状態に遷移するか」をテストで直接検証できるようになりました。将来的にオンボーディングのステップが追加・変更されても、状態遷移の定義を修正するだけで対応できます。 こうして、約1年5か月にわたるホーム画面リアーキテクチャが完了しました。Step4に関しては、ホーム画面に起因する障害や問い合わせは発生しませんでした。 Step3で体験したバグと、その後段階的に整備したテストが、実際の品質保証として機能している結果だと感じています。 ホーム画面リアーキテクチャ完了後、後続案件でホーム画面を触った他のメンバーから「実装が楽になった」というフィードバックをもらいました。これは、責務が適切に分割されたことで改修の影響範囲が把握しやすくなったことを示しています。 また、ログ周りの修正が入ったときも「テストで挙動が担保できるようになった」という声がありました。Step3で体験したバグに対して、Step3-ex以降で構築したテストが実際に機能している瞬間でした。 長期リファクタリングを進める上でのポイント 今回のリアーキテクチャを通しての学びやポイントは各ステップで紹介しましたが、全体を通じて特に重要だと感じた点として、 設計ドキュメントの継続的な整備 を挙げます。 設計計画(段階的なステップ計画、インタフェース設計)を文書化しておくことは、長期にわたるプロジェクトをチームで共有する土台になります。「なぜこの設計にしたか」が残っていることで、後続のステップでも一貫した判断ができます。また、AIを活用したコーディングが一般的になった現在では、設計方針が文書化されていることはより一層重要です。AIへの指示の精度が上がるだけでなく、生成されたコードがプロジェクトの設計意図と一致しているかの検証にも役立ちます。 おわりに このリアーキテクチャを振り返ると、最初は「アーキテクチャについての理解を深めたい」という動機から始まりました。しかし実際には、「テストの重要性」「段階的な変更の価値」「失敗を次に活かすこと」という、より本質的なことを学んだプロジェクトになりました。 特に、Step3後のバグ発覚→Step3-exのテスト追加→Step4でのTDD採用でバグ0を達成できたことは、自分の成長を強く実感できたポイントでした。 ZOZOTOWN iOSアプリのリアーキテクチャはまだ続いています。このホーム画面での経験をチームの資産として積み上げながら、より良いアプリを作り続けていきたいと思います。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com

動画

該当するコンテンツが見つかりませんでした

書籍