
iOS
イベント
該当するコンテンツが見つかりませんでした
マガジン
技術ブログ
はじめに こんにちは、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
はじめに はじめまして。株式会社タップルでサーバーサイドエンジニアをしている糸井一颯( Issa ) ...
はじめに こんにちは、スタメンでプロダクトエンジニアをしている おしん ( @38Punkd ) です。 5月9日、ウインクあいちで開催された フロントエンドカンファレンス名古屋 2026 にLT枠で登壇させていただきました。 その発表内容をブログ形式でご紹介できればと思います。 WebViewの文字サイズ、固定されていませんか? この問題は、ネイティブとWebの境界に起因する「実装責務の曖昧さ」から生じがちです。 モバイルアプリ開発において、ユーザーのアクセシビリティへの配慮は不可欠です。特に、スマートフォンの設定で文字サイズを大きくしているユーザーにとって、アプリ内のテキストがその設定に追従するかどうかは、使いやすさに直結します。 私たちのアプリ TUNAG は、モバイルアプリではアプリネイティブとWebView(モバイルアプリでHTMLを表示できる機能)を併用しています。ネイティブUIはOSの文字サイズ設定に追従する一方で、アプリ内のWebViewだけが標準サイズのままでした。文字のリサイズが画面ごとに適用されたりされなかったりすると、ユーザーにとって読みにくいコンテンツになってしまうため、WebViewへの適用を本格的に開始しました。 本記事では、iOSとAndroidそれぞれのWebViewにおいて、OSの文字サイズ設定を適切に反映させるための具体的な実装方法と、その際の注意点について解説します。 文字サイズ設定は「特別対応」ではない 文字サイズ調整は、多くのユーザーが日常的に利用する標準機能です。ある調査によると、 モバイルユーザーの約33%が文字サイズ調整を有効化している というデータもあります *1 。iOSのDynamic Type *2 やAndroid 14以降の200%フォントスケーリング *3 など、OSもこの機能を重視しています。 結論としては、 ネイティブアプリ側のごくわずかな修正と、Web開発時のただ1点のポイントをおさえるだけ で、WebViewでもiOS, Android共に文字サイズ設定が反映されるようになります。 対応工数に対して UX改善のインパクトが大きい領域 であると言えそうです。 iOS WebView:JavaScript注入による動的なフォントサイズ反映 iOSのWebViewでDynamic Typeを反映させる最も柔軟で推奨される方法は、ネイティブ側でOSのフォントサイズを取得し、JavaScriptを介してWebViewに注入するアプローチです。 ① Swift:Dynamic Type 変更を検知してフォントサイズを JS で注入 NotificationCenter. default .addObserver( self , selector : #selector(dynamicTypeDidChange), name : UIContentSizeCategory.didChangeNotification , object : nil ) @objc private func dynamicTypeDidChange () { applyFontSize() } func webView (_ webView : WKWebView , didFinish navigation : WKNavigation! ) { applyFontSize() // ページロード完了時にも適用 } private func applyFontSize () { let size = UIFont.preferredFont(forTextStyle : .body).pointSize webView?.evaluateJavaScript( "document.documentElement.style.fontSize = ' \( size ) px'" , completionHandler : nil ) } UIContentSizeCategory.didChangeNotification を受信したら、Swift側でフォントサイズを取得し、 evaluateJavaScript でWebViewの :root 要素の font-size プロパティに直接書き込みます。この方式は リロードが不要で即時反映され、独自のスケールにも柔軟に対応できる 点が利点です。 ② CSS: font-size は rem 単位で指定 Swift側で :root の font-size を動的に書き換えるため、Webコンテンツ側では rem 単位でフォントサイズを指定することで、すべてのテキスト要素が連動してスケールするようになります。 : root { font-size : 17px ; /* フォールバック。Swift が evaluateJavaScript で上書きする */ } .title { font-size : 1.143rem ; } /* ✅ :root 基準でスケールする */ .description { font-size : 0.857rem ; } /* ✅ :root 基準でスケールする */ 参考:よりシンプルな代替案( -apple-system-body ) CSSに :root, body { font: -apple-system-body; } を指定し、Dynamic Typeの変更検知時に webView.reload() を呼ぶ方法もあります。この方法は手軽ですが、 WebViewのリロードが発生する 点と、 Apple提供のスケールに固定される 点がデメリットです。 Android WebView:「対応できている風」に注意 AndroidのWebViewでは、iOSとは異なる挙動と注意点があります。特に「文字サイズ設定に対応できているように見えるが、実は問題がある」ケースに注意が必要です。 見た目 実際に起きていること 文字が大きく見える Activity再生成によりWebViewが作り直される 全体が拡大される 文字だけでなく画像・余白もズームされる場合がある 設定変更後に戻る スクロール位置・入力中状態が失われる可能性がある android:configChanges に fontScale が含まれていない場合、OSのフォントスケール変更時にActivityが再生成され、WebView全体がズームされます。これは文字以外の要素も拡大し、レイアウト崩れや状態喪失の原因となります。 文字だけを適切に反映できているか を見極める必要があります。 AndroidはOS値をJSで橋渡しする Androidで文字サイズ設定をWebViewに適切に反映させるには、ネイティブ側でOSのフォントスケール値を取得し、JavaScriptを介してWebViewに伝えるアプローチが有効です。 ① AndroidManifest: fontScale を configChanges に追加 <activity android : configChanges = "fontScale|uiMode|density" ... /> AndroidManifest.xml の <activity> タグに android:configChanges="fontScale" を追加し、フォントスケール変更時のActivity再生成を防ぎます。 ② Kotlin:フォントスケール変化を検知して WebSettings.setTextZoom(int) で反映 // 起動時の初期化 applyFontScale(resources.configuration.fontScale) // フォントスケール変化を検知(iOS の didChangeNotification に相当) override fun onConfigurationChanged(newConfig: Configuration) { super .onConfigurationChanged(newConfig) applyFontScale(newConfig.fontScale) } private fun applyFontScale(fontScale: Float ) { // fontScale 1.0 → 100(標準), 2.0 → 200(200%) // px・em 問わず WebView 内のテキスト全体をスケールする webView.settings.textZoom = (fontScale * 100 ).roundToInt() } onConfigurationChanged で newConfig.fontScale を取得し、 WebSettings.setTextZoom(int) でWebViewのテキストズームレベルを設定します。 ③ CSS:テキストは単位を問わずスケールされる WebSettings.setTextZoom(int) はレンダリングエンジンレベルで適用されるため、HTML/JS の変更は不要です。 px 、 em 、 rem のいずれの単位で指定されたテキストもスケールされます。ただし、非テキスト要素はスケールされないため、Web側で柔軟な設計が必要です。 共通の考慮事項:拡大しても壊れないレイアウトにする OSの文字サイズ設定をWebViewに反映させるだけでなく、Webコンテンツ側で、文字が拡大されてもレイアウトが崩れないような柔軟な設計が求められます。 避けたい実装 推奨する実装 固定高さ 内容量に応じて伸びる高さ 1行前提 折り返し・複数行を許容 アイコンと文字の密結合 gap・flex-wrap・min-widthで逃がす 文字サイズ対応の本質は、値の反映だけでなく「拡大を許容するUI」を構築すること にあります。 まとめ 本記事では、iOSおよびAndroidのWebViewにおいて、OSの文字サイズ設定を適切に反映させるための具体的な実装方法と、その際の注意点について解説しました。 iOSでは、Swift側でDynamic Typeのフォントサイズを取得し、JavaScriptでWebViewの :root 要素の font-size を動的に書き換えるアプローチが最も柔軟です。これにより、リロードなしで即時反映が可能となります。Androidでは、 AndroidManifest で fontScale の変更を検知し、 WebSettings.setTextZoom(int) でWebView全体のテキストズームレベルを設定します。 そして、iOSとAndroidの両方でWebView内のテキストをOSの文字サイズ設定に追従させるための共通の鍵となるのが、 CSSでの rem 単位の活用 です。 デフォルトの文字サイズ 拡大した文字サイズ OSではJavaScriptによる :root 操作と連動させるために rem 指定が 必須 となります。一方で、Androidの setTextZoom は単位を問わず追従してくれます。つまり、Web側の実装をiOSに合わせて rem に統一しておけば、Android側でも一切の不都合なく自然に拡大縮小が行われ、両OSに矛盾なく対応できるのです。 スマートフォンのOS設定を尊重し、より多くのユーザーに読みやすいWebコンテンツを届けることは、ユーザーの満足度向上に繋がりやすく重要です。本記事が参考になりましたら幸いです。 サンプルリポジトリ 本記事で紹介した実装の詳細は、以下のサンプルリポジトリでご確認いただけます。 iOSアプリ : GitHub - iOS WebView Dynamic Type Sample Androidアプリ : GitHub - Android WebView Font Scale Sample herp.careers *1 : Ian Savchenko, “Designing for Accessibility: How Text Resizing Works in Different Web Browsers,” PayPal Technology Blog. https://medium.com/paypal-tech/designing-for-accessibility-how-text-resizing-works-in-different-web-browsers-bed9e424e071 *2 : Apple Developer Documentation, “Scaling fonts automatically.” https://developer.apple.com/documentation/uikit/scaling-fonts-automatically *3 : Android Developers, “Features and APIs Overview — Non-linear font scaling to 200%.” https://developer.android.com/about/versions/14/features
















