📚

React NativeのiOSでSPMを用いたマルチモジュール化を行いました

2023/06/22に公開

こんにちは!アルダグラムでエンジニアをしている渡辺です

今回は React Native の iOS アプリ を SPM を用いてネイティブ開発を行えるようにしたので記事にしていきたいと思います!

背景

1年ほど前から React Native だけでは実装が難しい箇所をネイティブで開発を行うようになりました。

ネイティブ開発を進めていく中で React Native では当たり前に利用できたホットリロード機能がiOS のネイティブ開発では利用できませんでした。

また SwiftUI で開発を行っているので Preview 機能が利用できるのですが、利用するためにはアプリ全体がビルドされて Preview が表示されるまでに時間がかかってしまいます。そしてビルドが終わっても React Native 側の影響なのか正常に Preview を利用することが出来ませんでした。

SPM を取り入れることで機能や役割ごとにパッケージを作成することでビルド時間の短縮につながり、 Preview 機能を利用できるようになるメリットがあるため、 SPMを用いたマルチモジュール化を行うことにしました。

対応前の構成

ネイティブの全てのコードはReact Native のプロジェクト内の iOS ディレクトリ配下にまとめられており、 アーキテクチャは MVVM を採用していました。

ios
 |- KANNA
   |- View
     |- 機能a
     |- 機能b
     |- 機能c
   |- ViewModel
     |- 機能a
     |- 機能b
     |- 機能c
   | - Model
     |- 機能a
     |- 機能b
     |- 機能c
   |- Repository
     |- 機能a
     |- 機能b
     |- 機能c
   |- Resources
     |- image
     |- color
   |- Localize
     |- ja
     |- en
     |- th
   |- AppDelegate.swift
 |- Pods
 |- Podfile
 |- project.yml // Xcodegen
 |- サービス名-Bridging-Header.h

またReact Native で利用している CocoaPods でライブラリの管理を行なってました。

プロジェクトファイルのコンフリクト解消に Xcodegen を利用してました。

対応後の構成

機能や役割ごとにパッケージ分けを行いました。

もともと一つのターゲット内に全てのコードが入っていたため依存関係を考慮した開発を行なっていませんでしたが、パッケージで分割したことによって依存関係が明確になり開発する上で必要な依存は protocol を用いて依存関係を明確にするようにしました。

ディレクトリも ios 配下ではなく SwiftPackage ディレクトリにパッケージを格納しました。

また CocoaPods で入れたライブラリも、利用するパッケージごとに SPM で入れるように変更しました。

React Native Project
|- ios
 |- KANNA
  |- NativeModule
   |- 機能a
   |- 機能b
   |- 機能c 
  |- AppDelegate.swift
 |- Pods
 |- Podfile
 |- project.yml // Xcodegen
 |- サービス名-Bridging-Header.h
 |- SwiftPackage
  |- 機能aのパケージ
     |- View
   |- ViewModel
   |- Model
  |- 機能bのパケージ
     |- View
   |- ViewModel
   |- Model
  |- 機能cのパケージ
     |- View
   |- ViewModel
   |- Model
  |- Repositoryのパケージ
    |- Repository
    |- Model
    |- Converter
  |- Rのパケージ
     |- R.swift
     |- Resources
     |- image
    |- color
    |- Localize
     |- ja
     |- en
     |- th
   .
   .
   .
   .   
     

依存関係を表した図

実際の依存関係を明確にした状態をコードで表すと下記のようになります。

NativeModule

  • PackageA パッケージの PackageAView を呼び出します。
    • PackageAView を初期化するのに PackageARepositoryInterface を Props に渡します。
  • PackageARepository(PackageARepositoryInterface) を初期化します。
    • FirebaseAuthTokenProvider を受けて初期化されるようになってます。
    • NativeModule内で、repositoryを初期化する理由は、ネイティブだけでなく、React NativeにおいてもFirebaseモジュールを利用しており、FirebaseモジュールをiOSプロジェクト内で利用する必要があったためです。
// ios/Sample/NativeModule/NativeModuleA.swift
struct NativeModuleAView: View {
    init() {}
    var body: some View {
        PackageA(repository: repositoryA(firebaseAuthToken: FirebaseAuthToken()))
    }
}

// ios/Sample/Firebase/FirebaseAuthToken.swift
final class FirebaseAuthToken: FirebaseAuthTokenProvider {
    func getToken() -> String {
        return token
    }
}

PackageA パッケージ

  • 初期化時に PackageARepositoryInterface を継承した repository を利用して PackageAViewModel を初期化します。
  • viewModel が初期化時に getUserList が実行されて userList が取得されます。
  • PackageAView に 取得した userList から fullName が表示されます。
// SwiftPackage/PackageA/Sources/PackageA/View/PackageA.swift
public struct PackageAView: View {
         @StateObject var viewModel: PackageAViewModel
    public init(repository: PackageARepositoryInterface) {
        _viewmodel = StateObject(wrappedValue: PackageAViewModel(repository: repository))
    }
    var body: some View {
        VStack {
            ForEach(viewModel.userList) { user in
                Text(user.fullName)
            }
        }
    }
}

// SwiftPackage/PackageA/Sources/PackageA/ViewModel/PackageAViewModel.swift
public final class PackageAViewModel {
        let repository: PackageARepositoryInterface
    let userList: [User]
    public init(repository: PackageARepositoryInterface) {
       self.repository = repository
       Task {
           self.userList = await self.getUserList()
       }
    }
    
    func getUserList() async -> [User] {
        try awit repository.getUserList()
    }
}

Repository パッケージ

  • PackageARepositoryに、FirebaseAuthTokenProviderを注入することで、パッケージ内においてもFirebaseの必要な機能を利用できるようにしています。
    • これでAPIの実行が可能になりました。
  • Interface もここで定義しておきます。
// SwiftPackage/Repository/Sources/Repository/PackageARepository.swift
public final class PackageARepository {
    let apiClient: APIClient
        let repository: PackageARepositoryInterface
    public init(firebaseAuthToken: FirebaseAuthTokenProvider) {
       self.apiClient = APIClient(token: firebaseAuthToken.getToken())
    }
    
    func getUserList() -> [User] {
        // self.apiClient を使ってAPIを実行
                // 弊社ではApolloを利用してGraphQLのQueryやMutation を実行してます
    }
}

// SwiftPackage/Repository/Sources/Repository/Interface/FirebaseAuthTokenProvider.swift
public protocol FirebaseAuthTokenProvider {
    func getToken() -> String
}

// SwiftPackage/Repository/Sources/Repository/Interface/PackageARepositoryInterface.swift
public protocol PackageARepositoryInterface {
    func getUserList() -> [User]
}

※ それぞれのパッケージに利用するパッケージを Package.swift に記載する必要があります。Xcodegen の project.yml にも必要です。

メリット

Preview 機能を利用できるようになりました!

ひとまず Preview 機能が利用できるようになっただけでも今回対応して良かったと思います。

Xcode をパッケージごとに開くとパッケージだけでビルドが行われるのでかなり軽量で快適に Preview 機能を利用できるようになりました。

またそのパッケージに依存しているライブラリも SPM でインストールされるので開発で不便になることはないです。

ライブラリ管理が Package.Swift を利用できるようになる

以前まではライブラリ管理ツールは厳格に運用されておらず ReactNative で利用しているライブラリ等を CocoaPods で管理しているくらいでした。

しかしネイティブで開発していく中でネイティブ側でも利用したライブラリも増えてきて CocoaPodsを用いて依存ライブラリを管理したり、Xcodegenのproject.ymlに依存ライブラリを記述して、Xcodeに統合されたSPMを用いて依存ライブラリを管理したりと、複数の管理ツールを利用していました。

SPMでマルチモジュール化を行うことでパッケージ内は Package.Swift で管理ができるようになったため ReactNative で利用するものは CocoaPods, ネイティブで利用するものは Package.swift と分けて管理ができるようになりました。

また Package.swift はとてもシンプルに記述ができます。

// SwiftPackage/PackageA/Sources/PackageA/Package.swift

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "PackageA",
    platforms: [
        .iOS(.v15),
    ],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(
            name: "PackageA",
            targets: ["PackageA"]),
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(path: "../Repository"),
        .package(path: "../Model"),
        .package(path: "../R"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "PackageA",
            dependencies: [
                "Repository",
                "Model",
                "R",
            ]),
        .testTarget(
            name: "PackageATests",
            dependencies: ["PackageA"]),
    ]
)

ローカルパッケージやリモートパッケージを dependencies に追加するだけで利用ができるようになります。

パッケージ内のファイルの管理が不要になった

SPM はディレクトリ参照なため pbxprojファイル を必要としません。

なのでパッケージ内でファイルを追加してもxcodeprojファイルのコンフリクトが発生することがありません。

もともと Xcodegen を利用していたのでコンフリクトが発生することはなかったのですが

Xcode 以外からファイルを追加した場合だと Xcode 上で認識されることはなかったが、

SPM だとディレクトリ参照なので Xcodegen のコマンドを実行する必要がなくなりました。

パッケージ単位でのビルドが可能

今までは開発を行うのに全体ビルドを行なって動作確認などを行なっていました。

一度ビルドするのに20分くらいかかっていたので、かなりのタイムロスが発生していました。

しかしパッケージごとに開発を行うとパッケージ単位でビルドが行えるようになるためビルド時間を大幅に短縮することができました。

まだまだ React Native の箇所が多いのでまだそこまでの恩恵は受けれてはいませんが、今後ネイティブの箇所が増えていけばいくだけ恩恵を受けれるのでかなりのメリットになると思います。

デメリット

React Native ではあまり恩恵を得られない

React Native では CocoaPods を利用してモジュール管理を行なっているため、全てをSPM に置き換えることができなかったです。

全てを置き換えれないので Xcodegen は引き続き利用していくことになります。

またネイティブのパッケージは、React Nativeに依存していないので、React Native独自のライブラリに依存した機能は、ネイティブのパッケージでは利用できないのでそれなりに工夫が必要になってきます。

それでも React Native と Swift の繋ぎ込み箇所以外はすべて SPM に移行できたので満足はしています。

node_modules で利用しているモジュールはSPMでは使えない

React Native で開発を行う以上 node_modules は切っても切り離せない存在です。

node_modules も元はネイティブのコードで、依存ライブラリは CocoaPods で管理されています。

一方ネイティブの SPM パッケージは、React Nativeに依存していないので、node_modules(React Native)が依存しているライブラリに依存したモジュールは、ネイティブのSPMパッケージ内では利用できません。

今回は Firebase と 画像を表示するためのモジュールが利用できませんでした。

そのため Repository はSPMパッケージ化したけれど firebase 周りの処理をSPMパッケージに含めることができず、NativeModule で初期化して、SPMパッケージの View に Props で渡すようにするしかありませんでした。

外部モジュールを利用する際は依存関係に注意が必要となります。

React Native でネイティブ開発の土台を作る必要がある

React Native でプロジェクトを開始して自ら NativeModule を開発する機会があまりないと思います。

また加えて SwiftUI で開発を行うことはもっと稀なケースだと思います。

その分記事が少なかったりでネイティブ開発をスタートさせるまでの学習コストや Swift から React Native にブリッヂさせるための基盤を開発が必要となります。

まとめ

React Nativeで開発しているiOSアプリを、SPMを用いてマルチモジュール構成に変更することは、作業量も多く、デメリットも大きいですが、メリットもありました。

React Native で SwiftUI を利用することがかなりのレアケースではあるので SPMを用いることもないとは思います。

どうしても SwiftUI で開発がしたくて Preview 機能も快適に利用したい場合があれば一度チャレンジしてみるのもいいかもしれません。

私自身はネイティブ開発と Swift がまだまだ初心者ということもありたくさん学ぶことがあったので一つの経験としては大変よかったかなと思います。

今後ネイティブ開発が増えていくので、チームの開発効率が上がっていけば今回の対応は成功だと思えるので今後も改良を重ねながら経過観察していきたいと思います。

アルダグラム Tech Blog

Discussion