
Swift
イベント
マガジン
技術ブログ
本記事は「 Are AI coding agents actually getting better? 」を翻訳したものです。 Kiro IDE のコーディングエージェントがコードを書いたり変更したりすると、 diagnostics ツール が静的解析器を実行して出力をチェックします。これは、開発者がエディタ上で下線として目にするのと同じチェックです。実際にこのツールは、モジュールの import 漏れ( Cannot find module 'aws-cdk-lib' or its corresponding type declarations )、解決できない Java の import( The import org.junit cannot be resolved )、型の不一致( Argument of type 'string | undefined' is not assignable to parameter of type 'string' )、 implicitly typed any 、 undefined symbol といったものを検出します。これらは、本来であればビルド時になって初めて表面化するような種類の問題です。 Kiro での diagnostics ツールの実際の動きをおさらいするために、下のスクリーンショットはツールが動作している様子を示しています。次の TypeScript コードを考えてみてください。ツールは 2 件の 型の不一致 (Type ‘number’ is not assignable to type ‘string’ と Cannot assign to read-only ‘executionTime’)と、1 件の プロパティのハルシネーション (Property ‘itemAge’ does not exist on type ‘StackProps’)を報告します。これらの diagnostics は、エージェントに対して修正を生成し、変更を再検証するための具体的なフィードバックを与えます。この「生成 → 検証 → 修正」のループは、静的型付け言語を扱うときに頻繁に発生します。多くのよくあるコーディングエラーを、language server が早期に捕捉できるためです。 図1: diagnostics ツールの動作。エージェントは 3 件のエラー(25 行目の number を string に代入できない型不一致、45 行目の read-only な ‘executionTime’ への書き込み、55 行目の StackProps に存在しない ‘itemAge’ プロパティのハルシネーション)を検出し、ファイルを編集して再チェックし、残りエラーがゼロであることを確認しています。 この記事では、生成中のエージェントによる diagnostics ツールの呼び出しを調べます。すなわち、モデルがタスクの途中でどのくらいの頻度で自発的に静的解析器を呼び出すのか、それらの解析器がどのようなエラーを表面化させるのか、そしてモデルが最終的な編集を行う前にそれらを解決しているのか、という点です。 diagnostics は、開発者が IDE にインストールしている言語拡張機能によって生成されるため、チェックの内容はワークスペースの構成によって変わります。今回のデータでは、diagnostics は各エコシステムの標準的な解析器から生成されています。TypeScript/JavaScript は tsserver、Java は jdtls、Python は Pyright、Rust は rust-analyzer、Kotlin は Kotlin language server、Go は gopls、C/C++ は clangd、そして ESLint のようなスタイル・正当性のリンターです。Swift の diagnostics( Cannot switch on a value of type Region. Only convertible int values, strings or enum variables are permitted )も観測されました。さらに従来のコンパイルエラーの枠を超えて、Lean 4 の定理証明器の diagnostics も観測されており、ここではツールが構文の誤りではなく不完全な証明を指摘しています( Dependent elimination failed: Failed to solve equation, unsolved goals )。 この 6 か月間、Kiro はさまざまな モデルファミリーとバリアント をサポートしてきました。Opus 4.5 から 4.8、Sonnet 4 から 4.6 までです。 新しいモデルは単純にエラーを生成しなくなる、と予想するかもしれません。エラー率は確かに低下しているものの、実態はそれよりも込み入っています。これらのモデルは単に間違いを減らしているのではなく、異なる種類の間違いをするようになっているのです。以前の世代で支配的だったエラーのカテゴリが姿を消し、その代わりに新しいカテゴリが現れています。全体の軌跡は前向きですが、エラー構成のこの変化は理解しておく価値があります。この記事では、私たちが見つけたことを共有します。 最終状態の品質と、途中の自己修正シグナルを区別する。 AI コーディングエージェントが改善しているかどうかを評価するには、互いに補完的な 2 つの方法があります。1 つ目は事後(post-hoc)の静的解析で、エージェントの最終出力に対して固定の解析器一式を実行し、残存エラーを数える方法です。これは最終状態のコード品質(開発者が実際に受け取る成果物)を測るもので、「モデルは正しいコードを生成したか?」の最も直接的な代理指標です。2 つ目は本研究が調べるもので、生成中のエージェントによる diagnostics ツールの呼び出しです。すなわち、モデルがタスクの途中でどのくらいの頻度で自発的に静的解析器を呼び出すのか、それらの解析器がどんなエラーを表面化させるのか、そしてモデルが最終的な編集の前にそれらを解決しているのか、という点です。 この後者のシグナルは、2 つの理由からモデルの能力についてより多くを語ります。1 つ目に、これはモデルの自己監視の能力を捉えます。これは最終状態の計測では見えない振る舞い上の性質です。というのも、自分の成果物を一度もチェックせずにたまたまきれいなコードを出したモデルと、チェックしてエラーを検出し修復したモデルとは、最終状態だけを見ると区別がつかないからです。2 つ目に、呼び出しレベルのデータはより豊かな分解を可能にします。モデルが どの エラーカテゴリを生成するのか、そのうちどれを自律的に修正できるのか、そのために追加でどれだけのツール呼び出しがかかるのかを明らかにし、モデルの認知がどこで成功しどこで失敗するのかを粒度細かく見せてくれます。対して事後解析は、このプロセスを 1 つの合否ビットに畳み込んでしまいます。要するに、事後解析はユーザーが受け取った品質が 何であったか を教えてくれますが、diagnostics 呼び出しのデータはモデルがその品質を どのように 達成した(あるいは達成しそこねた)かを教えてくれ、時系列でのモデル改善の軌跡を診断するうえでより強力なシグナルになります。 データ 2026 年 1 月から 6 月までの 6 か月間の窓で、私たちは Kiro IDE における約 150 万件の会話を、7 つの Claude モデル(Opus 4.5 から 4.8、Sonnet 4 から 4.6)にわたり、TypeScript、Python、Java、Rust、Go、Kotlin、C++、Swift を含む複数の言語を対象に分析しました。データは Amazon 社内ユーザーから得たものです。 私たちは 40.6 万件の diagnostics 呼び出し、すなわちエージェントが静的解析拡張機能を使ってコードファイルをチェックする組み込みツールを呼び出した瞬間を抽出しました。Opus 4.5 と 4.6 が分析データの 51% 超を占め、ボリュームの大半を構成しています。より新しい Opus モデルはデータ点が少なめです。Sonnet 4.5 の呼び出しがさらに全分析リクエストの 45% を占め、Sonnet 4 と 4.6 はデータ点が少なめです。 これらの結果を解釈する際に心に留めておくべきことが 3 つあります。 私たちが測っているもの、測っていないもの。 ここで分析している diagnostics は、コーディングエージェントがファイル編集を行った後に実行される Kiro IDE の Diagnostics Tool から来ています。これはユーザーの環境にインストールされた拡張機能に依存します。典型的には language server や静的解析器(TypeScript の tsc、ESLint、Pylint など)に加え、リソースのプロパティ・必須引数・リソース参照をデプロイ前にチェックする CloudFormation や Terraform のバリデーターといったインフラ関連の拡張機能です。つまり、私たちが捉えているのは問題の一部にすぎません。ランタイムエラー、ロジックのバグ、パフォーマンス退行、そして動的解析やテストを要するものはすべて、このデータには見えません。diagnostics のチェックがクリーンであることは、コードが正しいことを意味しません。それは単に、コードが静的解析を通過したことを意味するだけです。 環境はユーザーごとに異なる。 利用できる diagnostics は、ユーザーがインストールしている拡張機能に依存します。厳格な ESLint ルールと CloudFormation バリデーターを備えた開発者は、最小構成の開発者よりも多くの警告を表面化させます。このことがユーザー間の比較をノイズの多いものにし、集計されたエラー率がコーディング品質 と ツールの厳格さの両方を混ぜ合わせたものになることを意味します。実務上は、AI のコーディング品質をエラー率に加えて、スタック別・言語別・環境別に評価すべきです。ただし、今回のデータは共通のツール標準とベストプラクティスを共有する Amazon 社内ユーザーから来ているため、このデータセットでの環境のばらつきは、一般的な開発者集団全体で見た場合よりも狭い可能性が高いです。 モデルの改善だけが変数ではない。 diagnostics は孤立して動作するわけではありません。steering プロンプト、hooks、subagents、その他のオーケストレーション層を含む、より広いシステムの中で動作します。これらのコンポーネントのいずれかがこの 6 か月の窓の中で変更されれば、モデルの能力改善とは独立にエラー率へ影響し得ます。ここで示された改善を、モデルのアップグレードだけにきれいに帰属させることはできません。一部はおそらく、周辺インフラの改善を反映しています。この 2 つを切り分けるには、オーケストレーション層を固定した対照実験が必要ですが、この観察データはそれを提供しません。 1. モデルはどのくらいの頻度で diagnostics を呼び出すのか? まず私たちは、 diagnostics 呼び出し率 、すなわちモデルが diagnostics ツールを少なくとも 1 回呼び出したコーディング会話の割合を調べました。この比率は、モデルが自分の成果物をチェックするために、利用可能な diagnostics ツールをどれだけ積極的に使うかを捉えます。 図2: 各モデルが diagnostics ツールを少なくとも 1 回呼び出したコーディング会話の割合。Opus 4.5 が 14.58%、Opus 4.6 が 22.26%(最高点)、Opus 4.7 が 10.15%、Opus 4.8 が 10.85%、Sonnet 4 が 7.65%、Sonnet 4.5 が 2.74%(最低点)、Sonnet 4.6 が 13.89%。 Opus 4.6 が最も積極的で、会話の 22.26% で diagnostics を呼び出しています。しかし、その後の Opus のバージョン(4.7 と 4.8)は約 10% まで戻りました。Sonnet ファミリーは別の物語を語ります。Sonnet 4.5 は diagnostics をほとんど呼び出しませんでした(2.74%)が、Sonnet 4.6 は 13.89% へ跳ね上がりました。これはツールへの意識の高まりを示す大きな増加です。全体として呼び出し率はおおよそ 3〜22% で、ほとんどの会話が、モデルが静的に見つかるエラーを積極的にチェックしないまま完了していることを示しています。これは、モデルは学習時のツールをデフォルトで使い、明示的なプロンプトやファインチューニングなしには diagnostics ツールをめったに使わない、という 最近の知見 と整合します。同様の実験は、エラーが解決されるまでエージェントの確定をブロックするために diagnostics を使うと、誤ったコードの受け入れを約 90% から約 8% へ大幅に減らせることを示唆しています。これは、diagnostics を単に任意のツールとして利用可能にしておくよりもはるかに大きな効果です。 2. チェックされたファイルあたりの報告エラー数: モデルは良くなっているのか? 私たちは、モデルのバージョンをまたいでファイルあたりの平均エラー数を比較しました。この指標は、生成されたコードがどれだけ「コンパイルに近い」かを捉えます。完全に成功しない場合でも、ファイルあたりのエラーが少なければ、手作業の修正が少なくて済みます。 図3: モデルのバージョン別の、チェックされたファイルあたりの平均 diagnostics エラー数。Sonnet の線は 3.01(Sonnet 4)から 2.90(Sonnet 4.5)、1.29(Sonnet 4.6)へ低下。Opus の線は 1.74(Opus 4.5)、1.21(Opus 4.6)、1.82(Opus 4.7)、1.21(Opus 4.8)で、両ファミリーとも最新版では 1.2 付近に収束。 Sonnet の線は明確な改善の物語を語ります。Sonnet 4 のファイルあたり 3.01 エラーから Sonnet 4.6 の 1.29 まで、57% の削減が観測されました。Opus ファミリーは非単調なパターンを示します。Opus 4.6 と 4.8 はいずれも 1.21 に達する一方、Opus 4.5 と 4.7 は 1.7〜1.8 前後とやや高めです。これは、Opus ファミリーの中では、静的に見つかる diagnostics の観点で必ずしもすべてのバージョンが厳密な改善を表すわけではない、ということを示唆しているのかもしれません。全体として、両ファミリーとも本研究の最新版ではチェック済みファイルあたり約 1.2 エラーへ収束します。これは、そのままでコンパイル可能なコードを生成するモデルへ向けた意味のある前進を示している可能性があります。 3. 呼び出しあたりのチェック対象ファイル数: 広いスコープか、狭いスコープか エラー率を超えて、モデルが diagnostics ツールを どう 使うかについて興味深いことに気づきました。新しいモデルは、1 回の呼び出しでより多くのファイルをチェックしています。 図4: モデルが diagnostics ツールを呼び出すたびにチェックする平均ファイル数。Opus は 1.78(Opus 4.5)から 1.87(Opus 4.6)、2.04(Opus 4.7)、その後 1.91(Opus 4.8)へ上昇。Sonnet は 1.57(Sonnet 4)から 1.72(Sonnet 4.5)、1.86(Sonnet 4.6)へ上昇し、新しいモデルほど 1 回の呼び出しでより多くのファイルをチェックしている。 初期の頃、Sonnet 4 は diagnostics を呼び出すたびに平均 1.57 ファイルをチェックしていました。実質的には一度に 1 ファイル、たまに 2 ファイル目という程度です。Sonnet 4.5 でこれは 1.72 へ、Sonnet 4.6 は 1.86 へ上がりました。Opus ファミリーも同様の傾向を示します。Opus 4.5 は呼び出しあたり平均 1.78 ファイル、Opus 4.6 は 1.87、Opus 4.7 は 2.04 でピークに達し、Opus 4.8 は 1.91 に落ち着きました。 これが重要なのは、コードチェック戦略の転換を表しているからです。ファイルを編集してそのファイルだけをチェックするのではなく、新しいモデルは 関連する ファイルをまとめてチェックすることが増えています。たとえば、実装とそのテスト、あるいはモジュールとその利用側です。 呼び出しあたり約 1.6 ファイルから約 2.0 ファイルへの移行はささやかに聞こえるかもしれませんが、数十万回の呼び出しにわたって見れば、モデルが以前の世代よりも高い頻度でクロスファイルの退行(import の破損、インターフェースの不一致、下流の型エラーなど)を捕捉していることを意味します。 4. 報告されたエラーカテゴリのトップ5 診断されたエラーが何なのかを理解するため、私たちはエラーの種類を分類し、各モデルごとに分布を可視化しました。 図5: 各モデルの diagnostics エラーカテゴリの分布。Opus 4.5・4.6・4.7・4.8、Sonnet 4・4.5・4.6 の 7 つのドーナツチャート。カテゴリは解決できない import、未定義シンボル、構文エラー、implicit any、プロパティアクセス、解決できない参照、Kotlin 標準ライブラリの解決、その他。解決できない import はすべてのモデルで最大のスライスで、Opus 4.7 では 57.6% に達する。一方 Sonnet 4.5 や Opus 4.5 のようなモデルはカテゴリ間により均等に広がっている。 すべてのモデルにわたって、「解決できない import」が単独で最大のエラーカテゴリであり、多くの場合すべての diagnostics の約 3 分の 1 を占め、Opus 4.7 では半分超に達します。「未定義シンボル」と、型システムのエラーの長い裾(構文エラー、解決できない参照、implicit any など)が残りを構成します。注目すべきは、エラー分布がモデル間で異なる点です。Opus 4.7 は import 解決の失敗に大きく偏っており、他のエラータイプは比較的少ないのに対し、Sonnet 4.5 や Opus 4.5 のようなモデルはカテゴリ間により均等に広がった分布を示します。これは、モデルによって失敗の仕方が異なることを示唆しているのかもしれません。あるモデルは主に依存関係の解決に苦戦し、別のモデルは型システムとシンボルの全体にわたって間違いをより広く分散させます。 5. ソースファイル対テストファイル: どちらがより多くのエラーを生むのか? テストコードは、モデルが正しく書くのが一貫して難しい対象です。テストにはモッキングフレームワーク、アサーションライブラリ、複雑なセットアップのパターンが関わり、テストツールとテスト対象コードの両方を理解している必要があります。 図6: モデル別の、ソースファイルとテストファイルにおけるファイルあたり平均エラー数。ソースエラーは青、テストエラーは赤。Opus 4.5 は 1.35 対 5.98、Opus 4.6 は 0.96 対 3.64、Opus 4.7 は 1.40 対 4.62、Opus 4.8 は 0.81 対 6.00、Sonnet 4 は 2.20 対 7.31、Sonnet 4.5 は 2.26 対 9.13(最も差が大きい)、Sonnet 4.6 は 1.08 対 3.56 で、テストファイルは常にソースファイルよりはるかに高い。 Opus ファミリーは概してソースファイルのエラーを 1.4 未満に保ちますが、テストファイルのエラーは 3.64(Opus 4.6)から 6.00(Opus 4.8)まで幅があります。Sonnet 4.5 は最も差が大きく、ソースファイルあたり 2.26 エラーに対しテストファイルあたり 9.13 エラーです。Sonnet 4 はソースエラー 2.20、テストエラー 7.31 を生み、Sonnet 4.6 は Sonnet の中で最も強く、それぞれ 1.08 と 3.56 です。モデルのサイズや世代にかかわらず、テストファイルが依然としてエラーの主な発生源であるように見えます。 6. 言語の地形図: Java は難しく、Python は易しい コードを生成するとき、他より多くのエラーを生む言語があります。Opus 4.6(最大のトラフィック)のファイルレベルのエラー率を見ると、そのばらつきは非常に大きいです。ただし、これらの数値はモデルの能力を超えた 2 つの要因によっても形作られています。顧客がインストールしている静的解析拡張機能の精度(より厳格なリンターほど多くの問題を表面化させる)と、Kiro IDE のユーザー層が各言語をどう使うかのばらつきです。 図7: Opus 4.6 における言語別のファイルエラー率(低いほど良い)。低い順に、JavaScript 1.6%、Python 4.0%、Go 8.0%、Kotlin 8.5%、TypeScript 8.6%、TSX(React)11.2%、C++ 12.2%、Rust 15.1%、Java 26.7%(最も長いバー)。 Java は 26.7% のファイルエラー率でトップに位置します。これは、生成された Java ファイルの 4 つに 1 つ超が少なくとも 1 件の diagnostics エラーを含むことを意味します。冗長な import、複雑なジェネリクス、検査例外、厳格な型解決の組み合わせが、本研究で分析した AI モデルにとって Java を最も難しい言語にしており、その差は大きく開いています。次に来るのは Rust の 15.1%、続いて C++ の 12.2% です。 中間層は TSX/React、TypeScript、Kotlin、Go で構成され、エラー率は 8〜11% の範囲です。これらはモデルをつまずかせるだけの構造を持った静的型付け言語ですが、Java ほど厳格ではありません。 最下位に位置するのは Python(4.0%)と JavaScript(1.6%)です。Python の動的型付け、import のボイラープレートの少なさ、寛容な構文は、一貫してきれいな結果を生みます。JavaScript の底値の 1.6% は少しばかり誤解を招きます。これはモデルがより良いコードを 書いている ことではなく、素の JS で利用できる静的解析が最小限であることを反映しています。TypeScript の型システムを上に乗せると、この率は 8.6% へ跳ね上がります。JavaScript に当てはまる同じ但し書きが、より微妙な形で Python にも当てはまります。動的型付け言語であり、静的解析が通常は軽いため、一部の Python のエラーはランタイムになるまで diagnostics として表面化しません。したがって、低い静的エラー率は編集時に捕捉できるものを反映しているのであって、そのコードが Java よりも必ずしも正しいことを意味しません。Java ではコンパイラがほぼすべてを前もって捕捉するのです。 チームが Java コードベースで AI コーディングエージェントを重点的に使っているなら、Python や TypeScript のコードベースよりも diagnostics のクリーンアップに多くの時間を費やすことを見込んでおいてください。 まとめ 6 か月と 50 万件近い diagnostics 呼び出しは、AI コーディングエージェントが正しいコードを書くうえで測定可能なかたちで改善していることを示していますが、その実態は単一の数字よりも込み入っています。 エラー率は低下している。 両モデルファミリーとも、より強力なバージョンでファイルあたり約 1.2 エラーへ収束します。 モデルは会話の 3〜22% で diagnostics ツールを積極的に呼び出す。 新しいモデルは一度により多くのファイルをチェックする。 より最近のモデルでは、関連ファイル(たとえば実装とそのテスト)をまとめてチェックすることが増えています。 解決できない import が支配的。 モデルにかかわらず、全エラーの約 30〜58% を占めます。 テストコードは正しく書くのが 3〜4 倍難しいように見える。 モッキングフレームワークやアサーションライブラリは、実装ファイルよりも一貫してはるかに多くのエラーを生みます。 言語は重要。 Java のエラー率(26.7%)は Python(4.0%)の 6.7 倍です。JavaScript の低い 1.6% は、より良い生成ではなく弱い静的解析を反映しています。 diagnostics は、Kiro のエージェントがコードを書いて洗練させる過程で自動的に実行されます。その仕組みを見て、さらに深く知るには、 diagnostics のドキュメント と モデルの概要 を読み、 Kiro をダウンロード してご自身のコードベースで試してみてください。
はじめに こんにちは、ZOZOTOWN開発1部iOSブロックのぎゅです( @kyuriza62 )。ZOZOに入社して3年目で、普段はZOZOTOWN iOSアプリの新機能の開発や、Storyboard/XIB形式の既存画面をSwiftへ置き換える作業などを担当しています。これまでもリファクタリングは担当してきましたが、影響範囲の大きい画面を扱った経験はありませんでした。 ZOZOTOWNのiOSアプリには、アプリ内のさまざまな導線からアクセスされる「カート追加画面」があります。今回は、このカート追加画面の実装を、ZOZOTOWN iOSチームで運用しているMVVM + Repositoryアーキテクチャへ段階的に移行するリファクタリングに取り組みました。本記事では、その過程で考えたこと、実際に手を動かして分かったこと、レビューを通じて学んだことをまとめます。 本記事で紹介するカート追加画面のリファクタリングは、ZOZOTOWN iOSチーム全体で取り組んでいるアーキテクチャ刷新事例の1つです。チームとしての取り組みや知識共有の仕組みについては、 ZOZOTOWNのiOSアーキテクチャの進化とチームの変化 にもまとめています。本記事と合わせて読んでいただくと、個々の取り組みとチーム全体の文脈を立体的に理解いただけます。 目次 はじめに 目次 今回のリファクタリング対象となる画面 カート追加画面とは なぜリファクタリングしようと思ったのか 既存コードの課題 課題1. 表示の流れを追いにくく可読性が低い 課題2. テストが書けない仕組みになっていた リファクタリングの進め方 今回のリファクタリングのスコープ 新機能案件との兼ね合い 不具合のリスク分散 意識したこと ViewModelのインタフェース(Input / Output)を先に決める 結合する前にテストを整える 新規実装を追加しやすい設計にする PRを小さく分割してレビューしやすくする 最終的に目指す形を見据えて進める 今後のリファクタリングのステップ リファクタリングのまとめ 前後で変わったこと 表示状態をViewControllerが抱える形をやめた 「更新を呼ぶ」から「状態を渡す」へ テストできる範囲が広がった 学び 既存の実装を担保しつつ、リファクタリングをいかに行うのか 正しく使うことを前提にした設計を避ける メソッド名と中身を一致させる 設計の根拠を言語化する 新アーキテクチャ導入時は既存パターンの安全性やデータの流れる順序を確認する PRのコメント機能を活用する おわりに 今回のリファクタリング対象となる画面 カート追加画面とは カート追加画面は、商品のサイズや色ごとの在庫を表示し、お気に入りの登録・解除やカートへの追加ができる画面です。商品詳細画面・検索結果画面・ランキング画面・お気に入り画面など、ZOZOTOWNアプリ内の非常に多くの画面から呼び出されます。 呼び出し元が多岐にわたるということは、この画面に何か問題が起きたときの影響範囲が非常に大きいということでもあります。実装を変更する際は、機能追加のしやすさだけでなく、既存のユーザー体験を壊さないことへの配慮が普段以上に求められる画面です。 なぜリファクタリングしようと思ったのか カート追加画面の実装は長らく手が入っておらず、チームで運用しているアーキテクチャ方針に則っていない状態でした。アーキテクチャ方針に則っていないコードは、レビューする側や実装する側の認知コストを高め、新機能の追加や既存機能の改善のハードルを上げてしまいます。リファクタリングの必要性は以前から認識されていましたが、案件の納期やリソース不足などの事情から、当該画面のコードベースを刷新せずに古い実装のまま踏襲する判断が続いていました。 そうした中、カート追加画面に関連する新機能案件が立ち上がりました。このまま新機能を追加すると認知コストの高いコードがさらに増えるため、この機会に合わせて複雑な画面のリファクタリングに挑戦したいと思い、カート追加画面のリファクタリングへ着手することにしました。 既存コードの課題 ZOZOTOWN iOSチームで運用しているMVVM + Repositoryアーキテクチャでは、ViewModelが画面に表示する状態の組み立てを担います。また、Repositoryでは、APIとの通信やレスポンスの変換などデータ取得に関する処理を担います。このように責務を分けることで、ViewModelは具体的なデータの取得方法を意識せずに済み、ロジックだけを取り出してテストできる状態を目指しています。 しかしカート追加画面を担っていた CartModalStockViewController は、いわゆるFat ViewControllerの状態になっていました。表示ロジックだけでなく、本来ViewModelやRepositoryに分離されるべきデータ加工やビジネスロジックまでViewControllerに直接書き込まれていました。表示に必要な状態はViewController自身がプロパティとして持ち、状態が変わるたびに画面の更新を明示的に呼び出す構造でした。また、コールバッククロージャを介したデータの受け渡しが複数の階層にまたがっており、複雑性の高いコードベースになっていました。 この状態が引き起こしていた課題のうち、リファクタリングを進めるうえで特に大きかったのは以下の2つでした。 課題1. 表示の流れを追いにくく可読性が低い 1つのクラスが表示・状態管理・ビジネスロジックを同時に抱えると、そのクラスを変更する理由が役割の数だけ生まれます。単一責任の原則から外れた状態のため、在庫の取得方法を変えたいだけでも、表示の組み立てや画面の更新まで同じクラスの中で影響を追うことになります。 この構造では、ある表示が最終的にどこで決まっているのかを追いにくくなります。表示状態が複数の箇所から書き換えられるため、1つを修正するたびに、他の表示への影響も毎回確認する必要がありました。 課題2. テストが書けない仕組みになっていた ビジネスロジックが UIKit 依存のViewControllerに書かれていたため、ロジックだけを取り出してテストできませんでした。テストを書くには、まずViewControllerを生成し、画面が表示される状態まで用意しなければなりません。そのうえでテストしたい処理にたどり着くには、表示中の状態も作り込む必要があり、準備だけで大きな手間がかかりました。 結果として、ビジネスロジックに関するテストが1つも存在していませんでした。つまり、変更後も正しく動いていることを保証する手段がなく、毎回手作業で確かめる必要がありました。 どちらの課題も、ViewModelを新たに用意して責務を分割することで解消を目指しました。課題の把握ができたので、次にリファクタリングをどこまでの範囲で、どのような計画で行っていくかを整理しました。 リファクタリングの進め方 今回のリファクタリングのスコープ リファクタリングの計画段階では、カート追加画面の全体をスコープとすることも検討していました。しかし、以下の2つの理由からリファクタリングのスコープをロジックの受け皿となるViewModelと、そこへ状態を流す仕組みを整えるところまでに絞る方針としました。 新機能案件との兼ね合い 実装着手前に調べたところ、この画面のコードは約7年にわたって大きな手が入っておらず、全体をリファクタリングするには最低でも3か月以上かかると分かりました。一方で、並行していた新機能の案件は、それ自体の難易度が高く、納期も決まっていました。アニメーション関連の実装、新しい画面の追加、新たに扱うデータ群への対応など、新機能の案件だけを見積もった時点で既にタスクが多く積み上がっている状態でした。 画面全部のリファクタリングを先に終わらせてから新機能に着手する案も検討しましたが、他部署と連携して進める新機能の納期を優先する判断としました。また、新機能もViewModelを用意してから実装した方がクリーンに書けるため、その土台となる最低限の範囲を先に整え、新機能と並行して進めることにしました。 不具合のリスク分散 範囲を絞ったもう1つの理由は、不具合が起きたときのリスク分散です。新機能とリファクタリングを同時にリリースすると、万が一不具合が発生した際に、新機能側の実装問題なのかリファクタリング側の実装問題なのか、原因の切り分けが難しくなります。データ取得をRepositoryへ切り出すところまで一度に進めると、変更範囲が新機能の実装と重なります。そのため、まず安全に移行できる範囲から確実に積み上げ、残りは新機能のリリース後、段階的に進める方針を取りました。 意識したこと ViewModelのインタフェース(Input / Output)を先に決める 既存のロジックを移し始めると、「この処理はViewModelに持っていくべきか、ViewControllerに残すべきか」で毎回判断に迷います。たとえば在庫を取得したあとにセクションを組み立てる処理は、どちらに置くべきか決めにくい部類でした。 そこで設計を始める前に、この画面でどんなデータが出入りするのかを整理しました。ViewModelへのInput(ユーザー操作やライフサイクルイベント)とOutput(画面表示のための状態)を、型として明示しています。実際の宣言は、InputとOutputをそれぞれプロトコルとして定義した次のような形になります。 protocol CartModalStockViewModelInput { /// 在庫情報を取得したときに呼ぶ func didFetchStock (items : [ StockItem ] ) } protocol CartModalStockViewModelOutput { /// 画面に表示するセクションの配列 var sectionsPublisher : AnyPublisher <[ StockSection ], Never > { get } } 先にInput / Outputを決めておくと、この判断が「Inputとして受け取るか、Outputに影響するか」という問いに置き換わります。最初に挙げた例なら、在庫の取得はInput、組み立てたセクションはOutputなので、組み立て自体はViewModelの役割だと判断できました。判断の基準が先にあることで、移す先に迷うことが減りました。 機能を足すときも同じで、新しいInputを増やすのか、既存のOutputに影響するのかという形で検討を進められます。 また、テストが書きやすくなるという利点もありました。検証する内容がInputを流してOutputを確かめる形に定まるため、何をテストすべきか迷わずに済みます。 結合する前にテストを整える ViewControllerとつなぐ前に、ViewModel単体のテストを書きました。決めておいたInputを流し込み、期待するOutputが得られるかを検証する形です。ViewModelは UIKit に依存していないため、ViewControllerと結合してシミュレータ上で画面を操作しなくても、テストだけでロジックの正しさを担保できます。ロジックとテストを先に揃えたことで、結合の段階では既存の挙動が変わっていないかだけを見ればよくなりました。 新規実装を追加しやすい設計にする この画面へ新しい機能を足すときに、チームで運用しているアーキテクチャに沿ったまま実装でき、誰が読んでも意図の分かるコードにしておきたいと考えました。独自の書き方を減らし、OS標準の仕組みへ寄せる方針で進めました。ViewControllerに残っている処理についても、同じパターンに揃えています。 画面の更新は、手動で更新メソッドを呼ぶ形から、 Combine の @Published で状態変化に連動する形へ変えました。表示する項目を新しく追加しても、値を更新したあとに画面へ反映させる呼び出しを書き足す必要がないためです。 テーブルの表示は、 UITableViewDataSource から UITableViewDiffableDataSource へ置き換えました。行の種類が増えたときも、スナップショットの組み立てを変えるだけで、差分の適用とアニメーションが自動的に行われるためです。 PRを小さく分割してレビューしやすくする 既存のViewControllerとの結合は、PRを小さく分割し、1つずつ挙動を確認しながら進めました。ViewModel側の正しさはテストで担保済みなので、レビューで見るべきは「つなぎ方が正しいか」「既存の見た目や挙動が変わっていないか」だけになります。実際、複雑な変更については動作を録画した動画をPRに添えて説明したところ、単なる確認で終わらず、より良い設計についての議論に発展したこともありました。 最終的に目指す形を見据えて進める スコープを絞って進めるとしても、最終的にどんなアーキテクチャを目指すのかは着手前に整理しておきました。ゴールが見えていれば、今回作るものが全体のどこに収まるのかが分かった状態で実装できます。最終的に目指すアーキテクチャは下図のとおりです。 今後のリファクタリングのステップ 今後の工程は、下図の7つのステップです。 カート追加画面にもともと存在していた在庫情報の取得・お気に入りの登録解除・カートへの追加といった中心的なビジネスロジックは、残りの7つのステップで順次移していく計画です。大きな流れとしては、ドメインごとにRepositoryを作り、テストを整えてから、ViewControllerのロジックをViewModelへ移していくという順序です。 工程を分けるうえで意識したのは、ロジックの土台となるデータ層を先に整えてから、ViewControllerとViewModelのつなぎ込み作業に移ることです。足場が揃ってからつなぎ込みに入れば、不具合が出たときに新しい層の問題か、つなぎ方の問題かを切り分けられます。 ドメインの順番は、依存関係の根元から決めました。お気に入りもカートへの追加も在庫の行の中にある操作なので、在庫のデータの形が決まらないと残り2つのインタフェースも決まりません。 リファクタリングのまとめ 前後で変わったこと リファクタリングの前後で、データの流れは下図のように変わりました。 リファクタリングする前は、ViewControllerが UITableViewDataSource へ更新を委譲し、再描画も呼び出す形でした。移行後は、ViewModelが配信する状態をViewControllerが受け取り、 UITableViewDiffableDataSource へ反映します。 表示状態の管理をViewModelへ移したことで、課題1に挙げた可読性の低さも、課題2に挙げたテストが書けない状態も解消しました。ここまでの取り組みを通して、大きく変わったことは以下の3点です。 表示状態をViewControllerが抱える形をやめた 移行前は、画面表示に必要な状態をViewController自身がプロパティとして保持していました。移行後は、ViewModelが配信する状態を受け取るだけになっています。 表示状態を書き換える箇所がViewModelに集まったため、ある表示がどこで決まるのかを追いやすくなりました。1つの表示を直すときに、他の表示への影響を毎回確認する必要もなくなりました。 「更新を呼ぶ」から「状態を渡す」へ もう1つ大きいのが、画面更新の考え方が変わったことです。移行前は、データを変えたあとにViewControllerから更新用のメソッドを呼ぶ必要がありました。つまり、呼び忘れれば画面は古いまま、順番を間違えれば意図しないタイミングで再描画される、という構造です。 移行後は、状態が変わってから表示に反映されるまでの流れをあらかじめ実装しておく形にしました。以降は状態を変えるだけで画面へ反映され、テーブルの更新もスナップショットを渡すだけになりました。 差分の計算やアニメーションの制御を自前で書く必要がなくなり、「更新処理を呼び忘れる」という不具合の種そのものが構造的になくなりました。 テストできる範囲が広がった 移行前は、この画面にViewModelのテストが1つもありませんでした。移行後は、画面の状態管理をViewModelが担うため、Inputを与えてOutputを検証するテストが書けるようになりました。 まだViewControllerに残っている処理はあります。一方、今回移した範囲では、「たぶん動く」ではなく、テスト結果を根拠として変更できるようになりました。 今回のリファクタリング範囲はすでにリリース済みで、大きな不具合の報告もなく安定して稼働しています。 学び ユーザー影響の大きい画面を慎重にリファクタリングする中で、特に自分の糧になった6つの学びを紹介します。 既存の実装を担保しつつ、リファクタリングをいかに行うのか 今回の大きな収穫は、既存のビジネスロジックを先に新しいViewModelへ実装し、そこにテストを用意してからViewControllerと結合したことです。テストが先にあると、結合で挙動が変わってもすぐに気づけます。 既存コードを読むだけでは、仕様として意図した挙動と、たまたま今そうなっている挙動を見分けられません。テストを書く過程で、その2つを1つずつ明文化できました。 先にテストを整備しておけば、結合の段階では既存の見た目や挙動が変わっていないかだけに集中できます。ユーザー影響の大きい画面ほど、この順番で進める効果が大きいと実感しました。 正しく使うことを前提にした設計を避ける 実装当初は、ViewControllerから画面の更新メソッドを呼び出す箇所が残っていました。レビューでは、呼び出し忘れが起きる設計になっている点を指摘されました。実装者が手順を守ることを前提にした設計は、守られなければそのまま不具合につながります。今回は状態に連動して自動的に更新される形へ見直し、呼び出し忘れという不具合の起き方そのものをなくしました。 メソッド名と中身を一致させる あるメソッドの内部でフィルタ処理を行っていた実装について、メソッド名からは中で値が絞り込まれていることが読み取れない、とレビューで指摘を受けました。1つのメソッドが名前から想像できない処理まで抱えていると、何がどの順番で起きるのかを呼び出し側から追えません。そこでフィルタ処理を呼び出し側へ移し、メソッドの責務を名前どおりの範囲に絞りました。隠れた処理が減るほど、変更時に確認すべき範囲も小さくなります。 設計の根拠を言語化する レビューで「なぜこの仕組みを採用したのか」と問われた際、明確な根拠を持って選んだものではなかったことに気づきました。結果的にその仕組みは不要と判断して削除しましたが、この経験から、技術選択の理由を都度言語化する習慣の大切さを学びました。 新アーキテクチャ導入時は既存パターンの安全性やデータの流れる順序を確認する UITableViewDiffableDataSource へ移行したあとも、以前の仕組みを前提にしたコードが一部に残っていました。新しい仕組みではデータが画面に反映されるまでの順序が変わるため、古い前提のまま動くコードは意図しない値を扱う可能性があります。実際に複数の箇所で同様の指摘を受けました。新しい仕組みを導入するときは、データがどの順番で流れて表示されるのかを正確に把握したうえで、既存コードがその前提と矛盾していないかを一つひとつ見直す必要があると学びました。 PRのコメント機能を活用する ある変更について「既存の挙動と変更なし」という一言のコメントだけを添えていたところ、レビュアーから詳細を聞かれました。実際の挙動を動画付きで説明したところ、単なる確認で終わらず、より良い設計についての議論に発展しました。複雑なロジックほど、テキストだけでなく動画などを活用して説明することの効果を実感しました。 これら6つの学びを振り返ると、レビューで気づかされたものには共通点がありました。どれも自分の中では分かっていたことが、コードからは読み取れない状態になっていた、というものです。 そこで今後は、PRを出す前に、初めて読む人の視点で自分のコードを読み直すことを意識しています。メソッド名から中の処理が想像できるか、複数の実装方法がある中でその仕組みを選んだ理由を説明できるかなどの観点を持っておくことで、レビューを待たずに自分で気づける範囲は広がると考えています。 おわりに 本記事では、ZOZOTOWNのカート追加画面を段階的にMVVM + Repositoryアーキテクチャへ移行するリファクタリングの取り組みを紹介しました。 ViewModelとテストを新たに整備したことで、これまでテストが存在しなかった処理の挙動をテストで担保できるようになりました。大規模なリファクタリングの経験が少なく、Fat ViewControllerを前に迷っている方にとって、本記事が何かしらのヒントになれば幸いです。 今後は引き続き、リファクタリングを完成に向けて進めていきたいと考えています。 設計の相談やレビューを通じて多くの気づきをくださったチームメンバーに、この場を借りて感謝いたします。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
トリビューでリードエンジニアをしています、志甫 (@shihochan_jp) です。 弊社では、美容医療アプリ「トリビュー」の4リポジトリ(iOS、Android、Webフロントエンド、バックエンド)から、Google Analytics・Braze・Adjustの計測定義279エントリ(イベントのほか、ユーザー属性や配信トリガーの定義も含む)を横断するカタログを、AIで自動生成する仕組みを運用しています[1]。 AIにコードを読ませてドキュメントを生成させると、次の2つが問題になります。 実行のたびに出力が微妙に揺れて、変更差分が読めなくなる コードに存在しないイベント名が、もっと
動画
該当するコンテンツが見つかりませんでした

















