TECH PLAY

Android

イベント

マガジン

技術ブログ

こんにちは。LINEアプリ開発SBU AIディベロッパーエクスペリエンスチームの onevcat(王 巍)です。最近は、AI エージェントを開発・検証のループに組み込むためのツールづくりに取り組んでい...
はじめに こんにちは、ZOZOTOWN開発本部ZOZOTOWN開発2部Androidブロックのにしみーです。 本記事では、レガシーなFragmentをKotlin + Jetpack Compose + MVVMベースの構造へリファクタリングした取り組みを紹介します。 対象画面のFragmentはJava製の共通基底クラスを継承しており、ロジック・状態管理・データ取得が基底クラスとFragmentに混在した構造でした。この構造を解体しながらリファクタリングを進めるにあたり、私たちは2つのアプローチを採用しました。1つは、実装前にAIと協働で「仕様調査 → 設計書 → タスク分解」のドキュメントを書き起こす、設計書から書き始める進め方です。もう1つは、ViewModelやRepositoryを後回しにしUIを先にComposeへ移行し、動かしながら段階的に進める工程設計です。 レガシーなAndroid実装をモダンな構造に置き換えている現場、肥大化した基底クラスと向き合っている方の参考になれば幸いです。 目次 はじめに 目次 背景・課題 解体したい対象 辛かった点 ── レガシーの実像 共通基底クラスへの強い依存 Object型のリストに見出しとアイテムが混在 イベントバスとRxJava 2による状態伝播 Fragment内でのDB直接アクセス 解決の取り組み 設計書から書き始める ── 実装より先にドキュメントを書ききる なぜ実装より先に設計書を書ききる必要があったか ドキュメントの中身 AIを雛形作成に活用する UIから先に組み立て、動かしながら段階的に進める ── ハードコードで動く画面を先に作る なぜUIから先に組み立てるのか フェーズ分割と段階的接続 リファクタリング後のアーキテクチャ sealed interfaceで画面状態を表現する sealed interfaceでリストアイテムを型安全に表現する AI活用Tips Tips 1:タスク分解書を「AIへの依頼の共通言語」にする Tips 2:設計ドキュメント自体もGit管理する Tips 3:レビューで得た知見を「AI参照用」のドキュメントに蓄える Tips 4:AIが苦手なところを人が補う まとめ 背景・課題 解体したい対象 今回対象にしたのは、ZOZOTOWN Androidアプリの「公式ショップから探す」画面です。この画面では、キーワード検索やソート条件をもとにショップ一覧を絞り込めます。検索やソートのロジックをアプリ内で保持していたため、実装が複雑になっていました。 また、この画面のFragmentはJava製の抽象クラスを共通基底として継承する形で実装されていました。基底クラス側に多くの責務がまとめられており、たとえば画面表示・状態管理・データ取得・アニメーション制御まで、多くの実装が集約されていました。サブクラスとなる画面側のFragmentは、基底クラスが定義する抽象メソッドを実装することで成り立っていました。 このような「基底クラスありき」の構成は、複数の画面に対する共通化を素早く実現する手段としては有効です。しかし長年運用するうちに、この土台に責務が積み重なり1000行を超える規模に膨らんでいきました。画面ごとの個別事情も吸収しきれず、気づけば画面1つの挙動を読み解くにはまず基底クラスを読み込まなければならない状態になっていたのです。 今回のリファクタリング対象は、その共通基底クラスを継承した画面のうちの1つです。共通基底クラスごと解体するわけではなく、対象画面のFragmentをKotlin + Jetpack Compose + MVVMベースの構造に作り直すのがスコープでした。Fragmentに集中していたロジックは、Composable・ViewModel・Repositoryへ分散させていきます。ただし、対象画面を作り直すには、基底クラスが担っていた責務をどこに引き取らせるかを先に決めなくてはなりません。そのため、土台ごと向き合いながら作り直す必要がありました。 辛かった点 ── レガシーの実像 リファクタリング着手前に、私たちが向き合わなければならなかった課題を共有しておきます。 共通基底クラスへの強い依存 共通基底クラスによってサブクラスでは多数の抽象メソッドの実装が必要になっていました。サブクラスは基底クラスが定めた枠組みに乗るだけで画面が成立するため、共通機能の修正は基底クラス1箇所に閉じるという保守性の利点もありました。 ただし、抽象メソッドの多くは「Viewを返すだけ」のgetterで、protectedな変数を介して基底クラスとサブクラス間で状態を共有していました。構造を抽象化して示すと次のとおりです。 abstract class BaseListFragment extends Fragment { protected List<Object> displayData = new ArrayList<>(); protected String searchText = "" ; abstract protected RecyclerView getRecyclerView(); abstract protected EditText getSearchEditText(); // ...続く } サブクラスから見れば、protected変数はいつ・どこから書き換えられているかが追跡しづらく、画面単体での挙動を読み解くのは困難でした。 Object型のリストに見出しとアイテムが混在 リスト表示にはObject型のリスト( List<Object> )が使われており、A〜Zなどの見出し行とその配下のアイテムが同じリストに混在していました。この構造は1つのRecyclerViewで「見出し + アイテム」を扱えるため、表示順序や挿入・削除を単一のリスト操作で表現できる利点がありました。 ただ、型情報は失われており、RecyclerViewのAdapter側ではキャストして表示を切り替えていました。コンパイル時の型チェックが効かず、リスト操作のたびにキャストが必要でコード補完やリファクタリングツールの支援も受けにくい状態でした。 イベントバスとRxJava 2による状態伝播 性別の変更やアイテム選択などのイベントはOttoによる静的なイベントバスで伝播され、購読側は @Subscribe で受け取っていました。データ取得はRxJava 2の Observable で書かれており、 subscribeOn / observeOn によるスレッド切り替えや map / flatMap での非同期処理を宣言的に書けました。これらは当時のAndroidで広く採用されていた、Fragment間通信と非同期処理の主流パターンでした。 一方で、イベントがコードを横断して飛び交うため、状態の流れを追うのに時間がかかりました。Disposableの解放管理もFragment側で行う必要があり、リソースリークの温床にもなりがちでした。 Fragment内でのDB直接アクセス Repository層は実質的に存在せず、FragmentからRoomのDAOを直接呼び出していました。レイヤーが少ない分、データ取得の実装はシンプルで見通しが良いため過剰な抽象化を避ける観点では合理的にも見えます。 しかし、検索ロジックもFragment内に実装されていたため、ユニットテストで切り出すにはFragment全体を起動する必要がありました。ビジネスロジックがプレゼンテーション層に貼り付いている状態は、テスタビリティを大きく損ねていました。 これらが組み合わさることで、画面1つの挙動を変えるだけでも基底クラスを読み解き、状態の流れを追い、影響範囲を見積もる作業が必要でした。Compose化や機能改修を進めるうえで、これは大きな足かせになっていました。 解決の取り組み 設計書から書き始める ── 実装より先にドキュメントを書ききる 通常、画面のリファクタリングは「コードを読みながら少しずつ手を入れていく」というアプローチで進められることが多いと思います。今回もそうしたかったのですが、共通基底クラスごと向き合う必要があったためそれは難しい判断でした。 なぜ実装より先に設計書を書ききる必要があったか 基底クラスは対象の画面以外にも継承されていました。場当たり的に対象画面側だけを書き換えていくと、他画面への影響や基底クラスから引き剥がした責務の置き場所が曖昧になっていきます。途中まで進めてから「結局この責務はどこに置けばよかったのか」と立ち止まると、それまでの作業がやり直しになるリスクもあります。 加えて、私たちはこのリファクタリングをAIエージェントと分担して進めることを前提にしていました。AIに任せる範囲を明確にするには、私たちとAIが共通言語として参照できるドキュメントが必要です。コードベースだけを見せて「いい感じにリファクタリングして」と頼んでも、設計判断はぶれてしまいます。 そこで私たちは、実装前に「仕様調査」「リファクタリング設計書」「タスク分解」の3点をドキュメントとして書ききることにしました。 ドキュメントの中身 それぞれのドキュメントは次の目的で書きました。 仕様調査は対象画面の現状の挙動を網羅的に書き起こすドキュメントです。UI構成、状態管理、データフロー、依存関係、アナリティクス、現状の問題点までを1つの「現状把握ドキュメント」としてまとめました。 リファクタリング設計書は、移行後のアーキテクチャを書き起こすドキュメントです。Repository/ViewModel/UIの責務分担、データ構造、フェーズ分割、各フェーズでの動作確認ポイントまでを記述しました。基底クラスが担っていた責務の配置先をこの段階で決定しました。 タスク分解は、設計書に沿って実装タスクを「画面表示」「フィルタ切り替え」「ソート切り替え」「検索」など、機能単位の細かい粒度に分解したドキュメントです。各タスク間の依存関係を明示し、並行して作業できる範囲やクリティカルパスも明記しました。 AIを雛形作成に活用する これらのドキュメントは、すべてを人が一から書いたわけではありません。私たちは主にClaude Codeを活用し、既存コードを読ませて雛形を出力させました。その雛形をもとに、人が事実関係をチェックしながら整えていく進め方を採用しました。AIが正しく拾えなかった箇所は人が補い、AIの捉え方が良かった部分は積極的に採用します。 ドキュメントを「AIと人の両方が読めるもの」として整えておくことは、実装フェーズで特に効果的でした。タスク分解書ではレビューしやすい単位を1つのタスクとして切り出しており、おおむね「1タスク = 1PR」の粒度になっています。実装フェーズではこのタスクごとにIssueを作成し、Issueの内容を参照してAIへ実装を進めてもらう運用としました。Issueには対応するタスクの記述を書き、必要に応じて設計書の該当箇所も転記しておくことで、AIは「どこから手をつけるべきか」「どこまでがスコープか」を迷わず進められるようになりました。 設計書を書く工数自体は決して小さくありませんが、実装フェーズに入ってからの迷いを減らすことができました。 UIから先に組み立て、動かしながら段階的に進める ── ハードコードで動く画面を先に作る 設計書を書ききった後、いよいよ実装フェーズに入ります。ここでは動作確認がしやすいように工夫して進めました。 なぜUIから先に組み立てるのか レガシーなFragmentをモダンな構造に置き換えるとき、素直にやろうとすると「UIも、状態管理も、データ取得も、イベント処理も全部一度に入れ替える」ことになります。これは変更差分が大きく、レビューと動作確認の両面で辛い状態を生みます。途中でバグが出ても、UI側のミスかロジック側のミスかが切り分けづらくなります。 そこで私たちは、まずUI部分だけをComposableとして組み立てるところから始めました。ViewModelやRepositoryの繋ぎ込みは後回しにして、ハードコードした仮のデータを渡して動く画面を先に作る進め方です。Composable関数は引数に渡されるデータの型さえ揃っていれば描画できるため、ロジックが未実装でもUIレイアウトやスクロール挙動などはすべて確認できます。 フェーズ分割と段階的接続 タスク分解書では実装フェーズを段階的に分けていました。順序はおおむね次のとおりです。 フェーズ1 :空のFragmentとComposable Screenの土台を作る フェーズ2–4 :UIを仮データで組み立てる フェーズ5 :ViewData/UiState/Repository/ViewModelの骨格を作る フェーズ6 :機能ごとに「ロジック実装 + UI接続 + Unit Test」を1セットで進める フェーズ7 :統合テスト・動作確認 各フェーズで意識したのは常にアプリが動く状態を保つことです。UIを組み立てている段階でも仮データを渡してアプリを起動すれば、実機でUIレイアウトやインタラクションを確認できます。フェーズ6の機能ごとの繋ぎ込みでも、1機能ずつ実データに接続していくため繋ぎ込んだ瞬間に動作確認ができます。 これにより、レビューも1機能単位の小さなPRで進められるようになりました。差分が小さければレビュアーの負担も減ります。次の章で扱うアーキテクチャの整理と合わせて「変更影響を局所化したまま、確実に進める」工程設計が成立しました。 リファクタリング後のアーキテクチャ 2つのアプローチを経て、対象画面はMVVMベースの構造に生まれ変わりました。Fragment内に集中していた検索ロジックやデータ取得処理はRepositoryに移しています。Fragmentフィールドに散在していた状態と、DomainモデルからViewDataへの変換はViewModelが担います。UI(Composable)はViewModelが公開するUiStateをStateFlowとして購読するだけになり、データの流れが一方向に整理されました。Beforeと比べて状態の流れが追いやすく、責務の切り分けも明確になっています。 責務を分散させるにあたって、特に型安全性を取り戻すことを意識した設計判断が2つあります。いずれもBeforeで挙げた「Object型のリストに見出しとアイテムが混在」「状態がFragmentフィールドに散在」という課題への直接的な解です。 sealed interfaceで画面状態を表現する 画面全体の状態は、 sealed interface を使ってContent/TextSearch/Errorの3つに分けました。構造を抽象化して示すと次のとおりです。 sealed interface ScreenUiState { val selectedFilter: FilterViewData data class Content( override val selectedFilter: FilterViewData, val items: List <ListItemViewData>, val sortType: SortType, ) : ScreenUiState data class TextSearch( override val selectedFilter: FilterViewData, val searchText: String , val items: List <ListItemViewData>, ) : ScreenUiState data class Error ( override val selectedFilter: FilterViewData, val errorMessage: String , ) : ScreenUiState } 旧実装では searchText 、 sortType 、 selectedGender のような画面の状態がFragmentフィールドに散在していました。これらを1つのUiStateに集約することで、状態の遷移はStateFlowを流れるUiStateの差し替えだけで表現できます。 具体的には、Content(テキスト絞り込みなしの一覧表示)・TextSearch(テキスト絞り込み中の一覧表示)・Error(エラーダイアログ)の3状態に分けています。 when 式での網羅性チェックも効くため、状態の追加や変更にも強い構造になりました。 sealed interfaceでリストアイテムを型安全に表現する 見出しとアイテムが混在していたリストには sealed interface で型を分けて表現する方法を導入しました。 sealed interface ListItemViewData { data class Section(...) : ListItemViewData data class Item(...) : ListItemViewData } これにより、LazyColumnの items などでリスト要素を扱うコードは when 式で分岐するだけで網羅的に処理できます。キャストもなく、見出しとアイテムの取り違いもコンパイル時に検出されます。Object型のリストでキャストを繰り返していたBeforeと比べて、型システムが安全に守ってくれる範囲がぐっと広がりました。 AI活用Tips 最後に、このリファクタリングを通して得られたAI活用のTipsをいくつか共有します。 Tips 1:タスク分解書を「AIへの依頼の共通言語」にする 設計書とタスク分解書は、実装フェーズで「AIへの依頼の共通言語」として活用しました。私たちはタスク分解書の1タスクごとにIssueを作成し、Issueに対応するタスクの記述を書く運用にしました。必要に応じて設計書の該当箇所も転記しておくことで、コードベースだけでは判断しづらい「どこから手をつけるべきか」が伝わり、AIの出力は私たちの期待に近づきやすくなりました。 Tips 2:設計ドキュメント自体もGit管理する 実装を進めていると、「設計変更が必要になる」場面は必ず出てきます。たとえば今回も、当初はUiStateをContent/Empty/Errorの3状態で設計していました。しかし実装中にEmptyを独立させるよりテキスト絞り込み中の状態として表現する方が適切と判断し、TextSearchへ置き換えました。他にもタスク分解の途中で機能の依存関係を見直したり、Repository層に切り出す責務の境界を再定義したりと、設計書を書き換える場面が複数回ありました。 私たちは設計書やタスク分解書をマークダウンとしてリポジトリに置き、Gitで履歴を追えるようにしています。変更があった際はコミットを切り、なぜ変えたかをメッセージに書き残します。これによりAIが設計書を参照するときも常に最新の状態を渡せますし、レビュアーも「設計と実装の差分」を追いやすくなりました。同じような取り組みをするチームには、設計書を書いた段階から「変更を前提にドキュメントを置く場所と更新ルールを決めておく」ことをおすすめします。 Tips 3:レビューで得た知見を「AI参照用」のドキュメントに蓄える 個別のレビュー指摘はそのPRで修正して終わりにしてしまいがちです。しかし「次に同じ画面を触るとき」「次にAIに似た実装を依頼するとき」に、また同じ指摘を繰り返さないようにしたいところです。私たちはレビューで頻出した観点やコーディング規約に書ききれていない暗黙のルールを整理し、リポジトリ内のドキュメントとして残しています。たとえば、HiltのスコープごとのDIモジュール構成や、Coroutinesでのスレッド切り替えを抽象化するためのプロジェクト独自ルールなどです。Android開発ではフレームワークやプロジェクト固有の制約が多く、コードを読むだけでは伝わりにくい暗黙のルールが意外と多くあります。AIへの実装依頼時にこれを参照させることでPRの品質が安定し、レビューの往復回数も減っていきました。 Tips 4:AIが苦手なところを人が補う AIに任せれば実装まで一気通貫で終わる、というほど単純ではありませんでした。仕様調査や設計書の雛形作成、機能単位のIssueから始まる定型的な実装はAIに頼りやすい一方で、人の判断が必要だった場面もいくつかあります。 たとえば、複数画面にわたる責務の整理、Repository層に切り出す境界の判断、実装方針のトレードオフを取る場面などはAIに丸投げするとブレやすい部分です。これらは対象の画面以外の前提知識やプロジェクトの方向性を踏まえた判断が必要なためです。こうした判断には、人がドラフトを書いて設計書に落とし込み、それをAIに参照させて実装してもらうというハイブリッドな進め方が結果的に効率的でした。 まとめ 本記事では、共通基底クラスを継承したレガシーな画面をKotlin + Jetpack Compose + MVVMへ移行する際に採用した2つのアプローチを紹介しました。1つは実装より先に設計書を書ききること、もう1つはUIを先にComposeへ移行し動かしながら段階的に進めることです。 これらを組み合わせて進めた結果、状態管理とリスト表現の型安全性も取り戻し、Fragmentに貼り付いていたロジックをRepositoryとViewModelへ整理できました。 リファクタリング前は対象画面のロジックがFragment内に閉じており、ユニットテストが書きづらい構造でした。リファクタリング後はViewModelやRepositoryの単位で70件以上のユニットテストを実装できる構造になり、その後の関連機能の追加・修正もユニットテストで検証しながら進められました。 レガシーなAndroidViewのCompose化や肥大化した基底クラスの解体と向き合っているチームの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに QEグループでモバイルチームに所属しているmです。 前職までは約7年ほどモバイルアプリの開発をメインに従事していました。 今はQAとしてプロダクト開発に関わっています。 この記事では、QAが設計や実装といった開発側との共通言語を持っておくと、テスト設計でも不具合対応でも有効である、という話を書きます。 共通言語があると問題の切り分けがしやすくなり、開発とのコミュニケーションコストも下がるため、本来集中すべき業務に工数を割きやすくなると考えています。 なお現在も対応中の取り組みなので、効果については「こう感じている」という話も含みます。 課題:内部の変更は、影響範囲が外から見えづらい まず、この記事の出発点となる課題から整理します。 内部実装だけが変わる対応、特にユーザーから見える動作は変えないリファクタリングなどは、影響範囲が外から見えづらいという特徴があります。 例えば、Swift6対応やKMP対応、UIライブラリの移行などが該当するかと思います。 挙動レベルの影響は開発から共有されるものの、外から触っているだけではどこがどう変わったのかを把握しにくいです。 そのため、テスト設計では「影響範囲が分からないので、念のため全項目をテストしよう」となりやすく、 不具合対応では「ゼロから原因を探そう」という方向に傾きやすくなります。 ただ、実際には期日などの制約もあるため、すべてを見るわけにもいかない場合があります。 この「影響範囲が外から見えづらい」という点が、このあと述べる2つの工程(テスト設計と不具合対応)に共通する課題になります。 前提:アプリ側の設計・実装を把握して見る 課題への向き合い方として、まず普段行っている見方を記載します。 アプリは一枚岩ではなく、機能や役割ごとに分かれて作られています。 たとえば、画面の見た目を作る部分、データを加工する部分、サーバーと通信する部分、データを保存する部分、といった具合です。 何かが起きたとき、「画面上どう見えるか」だけで捉えるのではなく、 「それはどの責務の話なのか」「どことつながっているのか」を、アプリの作りの上で考えるようにしています。 これが、本記事で言う「構造から見る」という見方です。 たとえば「ログイン後の表示がたまにおかしい」という事象であれば、見た目を作る部分ではなく、 データを取得する通信や処理の部分が怪しいのではないか、と当たりをつける、といった具合です。 もちろん開発経験があるため、コードや設計資料を見れば、どの部分がどのように動き、どこにつながっているかは比較的把握しやすいほうだと思います。 ただ、この見方だけに頼るのではなく、開発側にも影響範囲の資料を展開していただいています。 そして、この見方がまず効くのが、テスト設計です。 テスト設計:見るべき範囲を、根拠を持って絞る 先ほどの「つい全項目をテストしたくなる」場面を、前項の考え方で設計します。 ここで行うのは、実装を隅々まで読むことではありません。「この変更は何をしようとしているのか」「だとすれば、何が壊れそうか」を、設計レベルで把握することです。 ここが分かると、何を見るか・どこまで見るかの手がかりになります。 具体的には、次の3つを問いとして使っています。 ①動作は変わるのか、変えないはずなのか 変えないはずの変更であれば、見るべき軸は「変更前と同じか(同等性)」になります。見た目や操作感が、変更前と変わっていないかを確認する、ということです。 ②技術的にどこへ影響のある変更なのか 並行処理なのか、UIの作りなのか、通信まわりなのか。 影響する領域が分かると、その領域で起きやすい問題が、見るべき観点になります。 たとえば並行処理が変わるのであれば、断続的に固まる・クラッシュする・反映が遅延する、といった点です。 ③どの単位で入る変更なのか 画面単位なのか、モジュール単位なのか。これが分かると、見る範囲の単位が定まります。 たとえば、今回改修した画面のみを対象にする、といった具合です。 この3つの問いで根拠を持って絞れると、見る範囲が現実的なところに収まります。 「全部やる」から「変更によって壊れそうな箇所を確かめる」へ変わる、という感覚です。 あわせて、開発との「どこまで実施するか」のすり合わせも速くなると考えます。 後工程でも効く:不具合の起票と切り分け ここまではテスト設計の話でしたが、メリットはその先の工程にも続くと考えています。 テスト設計の段階で把握した「この変更はどの実装箇所に影響があるか」という理解は、不具合の起票や切り分けの場面でも、そのまま活用できます。 一度読み込んだ作りが、ここでも改めて効いてくる、という形です。 そのため、案件にもよりますが、起票を見た時点で「このあたりが怪しいのではないか」と当たりをつけ、仮説つきで起票できます。 たとえば、表示崩れ・断続的に固まる、iOS・Android共通の不具合、といった切り口で、どの部分の話なのかを見立てられます。 重要なのは、これは「QAが原因を完璧に特定できる」という話ではない、ということです。 あくまで当たりをつけ、仮説を立てるところまでです。ただ、その仮説があるかないかで、その後の動きはかなり変わると考えています。 「画面が固まりました」とだけ書かれた起票と、 「ここは非同期処理の変更が入った画面なので、そのあたりが怪しいかもしれません」という仮説つきの起票とでは、 開発側が状況を把握する速さも、誰に依頼すべきかの的確さも変わってきます。 切り分けが、「ゼロから探す」から「仮説を検証する」へ変化し、開発とQA双方にとってメリットが生まれるかと思います。 実装に寄りすぎない ひとつ、意識していることがあります。 現在の実装に寄りすぎると、「すでに実装されているもの」をなぞるだけになり、本来あるべきなのに、実装として抜けている観点を見落とすおそれがあります。 そのため、開発から共有された「本来こう動くべき」という挙動レベルの情報と、 コードや資料から読み取った「現状こうなっている」という状態を、突き合わせるようにしています。 構造理解で当たりをつけつつ、「ユーザーから見てどうあるべきか」という挙動ベースの視点も手放さない。 このバランスが大事だと考えます。 まとめ 今回の内容を要約すると、こうなります。 構造を理解しておくと、テスト設計でも後工程でも、QAの動きが「探す」から「検証する」へ変わる。一度の歩み寄りが、複数の工程で効いてくる。 もちろん、自身の背景などから知見があったという側面はありますが、それがないと無理な話ではないと考えています。 まずはプロジェクトのアーキテクチャ図や設計資料を読んでみる、詳細設計の境界を意識してみる、変更がどの設計箇所に影響するのかを開発に聞いてみる。 そうした小さなところからでも、見立ては変わってくるかと思います。 内部実装の変更で「全部見るしかない」となりがちな場面でも、構造から攻めることで、影響範囲の見立ての精度を上げられます。 同じような場面に立っている方の参考になれば嬉しいです。

動画

書籍