📦

スケールを見据えたiOSアプリの基盤設計

に公開

はじめに

新たに iOS ネイティブアプリを開発すると仮定し、基盤設計を紹介していきます。
個々のワークアラウンドではなく、全体を俯瞰した構成を一つの事例としてまとめました。
本記事はベストプラクティスを示すものではありません。
なお、状態管理やアプリ固有のロジックについては扱いません。

下記のような読者を想定して書きました。

  • 小規模チームでアプリ開発を効率的に行いたい
  • スケールに備えた最低限の構成を整えたい

想定するアプリ開発環境

本記事では、以下のような前提条件を持つ一般的なモバイルアプリを想定して解説してきます。

  • iOSネイティブアプリでXcodeを主に利用する
    • SwiftUI/UIKitのフレームワークは問いません
  • パッケージ管理にはSwift Package Managerを利用する
  • 外部APIを利用する
    • 自前のバックエンドサービスや外部サービスが提供するREST APIを利用
    • APIの使用はOpenAPI (Swagger)により定義されている
  • 複数環境を切り替えて開発する
    • ローカルで起動するMockサーバ
    • チーム開発・検証用のDevelopmentサーバ
    • 本番運用のProductionサーバ

この記事で紹介すること

上記の前提から以下の要素について記述していきます。

  • プロジェクト構成方針
  • 環境分離の実装
  • パッケージの分割と依存ルール

Workspaceの構造

今回の開発体験を満たすための全体像から解説します。
複数のサーバ切り替えやソースコードの整理が必要なためにXcode Workspaceを利用します。Xcode Workspaceは複数のProjectやパッケージを束ね、外部ファイルの参照などが行える標準ツールです。

Workspaceの設定は大きく「Project」「Package」「Group」と大別できます。
全体として、アプリの設定はProjectに、本体はPackageに分けるという構成をとりました。

  • Project
    • Production: リリースビルド用
    • Development: 開発サーバ用
    • Mock: ローカルサーバ用
  • Package
    • App: アプリケーションコード
    • OpenAPI: 生成コードの利用
    • Macros: 独自マクロがある場合の運用
  • Group
    • Scripts: プロダクト外のドキュメントなど

Xcode workspaceの全体構成図
Xcode workspaceの全体構成図

Project

今回の構成では、「APIサーバや外部サービスの切り替え」を満たすためにビルドごとに設定します。
それぞれのサーバURLや各種外部サービスに必要なKeyなどをそれぞれの環境で用意し分離します。
スキーマごとに用意しスクリプトで変更する方法などもありますが、分離させると完全に独立した環境が作成可能であり、ビルドの成果物も別物になります。
署名やBundle IDなどを分けておくことで簡単にビルド環境の切り替えも可能です。

今回は「Mockサーバ」「Developmentサーバ」「Productionサーバ」の3つに合わせた環境をそれぞれ用意しました。

なぜProjectを分離するのか

Target Membershipを用いて単一のProject内に複数ターゲット(MockApp, DevelopmentApp, ProductionApp)を所属させることもできますが、不便な点があり今回のアプローチを取りました。

まず、Project側の設定を簡易化する目的があります。
アプリの設定にはBundle ID、Provisioning、Entitlementsなどさまざまな固有の設定が必要です。
さらに、外部サービスのための独自Info.plistが増加するなど、一つのターゲットで管理するには煩雑になりがちになります。
それぞれのBuild Settingsが必要な情報を持ち、集約されることで差分の追跡やレビューも容易です。
また、CIの観点から見ると必要なビルドを得るためにはそのプロジェクトをビルドするだけになり、環境切り替えや設定のミスが大幅に減少します。

環境ごとの変更例

それぞれのProjectのBuild Settingsで下記を編集することで設定できます。

  • Project(.xcodeproj)
    • Bundle ID
    • 表示名
  • Build Settings(User-Defined)
    • APIベースURL
    • ログ監視API Key

パッケージへの変数の渡し方

Info.plist に流して Bundle.main から読みます。
特定のモジュールを設定読み出しのツール(AppConfigなど)として定義し、その他のモジュールが依存すると呼び出し方法が統一されて良いでしょう。

enum AppConfig {
  static var apiBaseURL: URL {
    let key = "API_BASE_URL"
    guard let str = Bundle.main.object(forInfoDictionaryKey: key) as? String,
          let url = URL(string: str) else {
      fatalError("Missing or invalid \(key) in Info.plist")
    }
    return url
  }
}

Scripts

Scriptsフォルダは特別必須なわけではありません。
このフォルダにはREADME.mdやXcode Cloud用のスクリプトフォルダ(ci_scripts)、最近ではAIコーディング用のAGENTS.mdや関連ファイルをまとめておくと、参照・編集がしやすくなるのでおすすめです。

Package

ここからはアプリの根幹となるソースコードについて整理します。
実装自体がPackageに集約されることで、例えばStaging環境を増やしたい、といった要件でもコード修正をする必要はなく、前述したProject側の設定のみで完了します。

Appパッケージ

Appはアプリケーションの本体です。

Appパッケージの中身については後で深掘ります。
画面の実装やビジネスロジックなど大部分のコードを集約し、各環境変数は前述した方法でProjectから提供します。

OpenAPIパッケージ

OpenAPIはOpenAPI/Swaggerから自動生成したAPIクライアントコードを置くためのパッケージです。
生成コードは更新頻度が高く、差分も大きくなりがちなので本体とは分離して扱います。
Package Pluginとして生成する方法や事前にビルドする方法などは個々のユースケースで分かれるため今回は扱いません。
ここではOpenAPIを例としていますがこの考え方を利用すればGraphQLやgRPCにも転用が可能です。

Macrosパッケージ

MacrosはSwift Macrosを使ってボイラープレートを生成するためのコードを切り出したパッケージです。
マクロはビルド時に展開される仕組みであり、アプリ本体の責務とは性質が異なるため、Appから分離し管理します。

独自の実装が必要なときなどに利用します。

下記のリストで十分な場合はdependencyとして利用したりもします。

https://github.com/krzysztofzablocki/Swift-Macros?tab=readme-ov-file

Packageの依存関係

依存関係はシンプルにAppがOpenAPIとMacrosに依存します。

Appパッケージの深掘り

ここからはAppパッケージの分割方針と依存ルールについて説明します。

モジュール分割の方針

目的

ソースコードを一つの巨大ターゲットとして管理することもできますが、差分ビルドも効かず依存関係も曖昧になります。
特定の目的に応じたモジュールに分割していくことで下記のメリットがあります。

  • 依存関係が明確になる
    • 例: UIから外部通信や保存のコードを直接呼ばせない。
  • 責務の方向がコンパイルで強制される
    • 例: UIモジュールなどが相互依存するとビルドが通らないのでコードの一貫性が保てる
  • テストがモジュール単位で書ける
    • 例: 必要なビジネスロジックだけ、など最小限のUnit testが行える
  • レビュー・並行開発がしやすい
    • 例: 変更がモジュール単位で明確になり、差分が読みやすい

モジュール化の単位

モジュール分割の単位は難しく、いくらでも細分化できますが最低限下記の単位であれば、スケールしやすいと感じます。

  • Feature: UI + 状態管理 + 画面ロジック
  • Repository: ビジネスロジック
  • Client: 外部APIや永続化への接続
  • Entity: ドメインモデル
  • Library: 共通ユーティリティ

モジュールの依存ルール

上記モジュールを基本概念として各モジュールは以下のように依存します。

原則FeatureはClientを呼び出さず、Repositoryを経由します。
OpenAPIなどの生成コードはClientを通しアプリコードに合わせた呼び出し方をします。

具体的には下記のように必要なモジュールを適宜追加し運用していきます。

共通化コードの参照

アプリのエントリーポイントではAppパッケージが公開する特定のtargetを参照します。
AppFeatureを例に取り、下記のように宣言することで各Projectがコードを共有することができます。
AppFeatureはメインコンポーネントとなるAppViewを公開し、Repositoryの注入などの依存を組み立てます。

import AppFeature
import SwiftUI

@main
// MockApp or DevelopmentApp or ProductionApp
struct ProductionApp: App {
  var body: some Scene {
    WindowGroup {
      AppView()
    }
  }
}

この時struct名をそれぞれの環境で変える必要はありませんが、Productionの時のみ対応したい事やDebugのためのViewを差し込むときなどに判別しやすくするため変更しています。

まとめ

アプリ開発基盤の事例として解説してきました。

  • Xcode Workspaceを利用し、環境設定はProjectに、実装はPackageに集約する
  • アプリ本体のパッケージは Feature/Repository/Client/Entity/Libraryの依存ルールでモジュール分割・レイヤー化する

小規模で複数人開発する場合に役立つのではと思い、記事としてまとめました。
より良いアプリ開発の参考になれば幸いです。

Discussion