はじめに
SARAHのiOSアプリは2015年にリリースされましたが、ついにSwiftUIへの移行が決定しました!それに先駆けSwiftUIにまつわるコードを独立したフレームワーク内に閉ざして管理するために、SARAHとしては初の埋め込みフレームワーク (Embedded Framework) を導入したので、その時の知見を記そうと思います。
Embedded Framework とは
埋め込みフレームワークはその名の通り、一つのプロジェクト内で役割毎にフレームワーク分割できる仕組みです。フレームワークではあるものの、外部からインポートするのものとは違い、プロジェクト内に直接埋め込みます。
例えば以下のような構成でのレイヤー分割が考えられます。
MyProject
├─ View -- レイアウトにまつわるコードを包括
├─ Core -- ビジネスロジック
└─ Infra -- 通信やDBなどのコードを包括
レイヤーを跨いでコードを参照する際は import
を明記します
import SwiftUI
import Core // <-💡
struct SomeView: View {
let someViewModel: Core.SomeViewModel // <-💡
var body: some View {
...
}
}
【メリット】
責務の明確化
↑の例で示した通り、分割したレイヤーがそのままディレクトリ構成として表現される且つ限定的なアクセススコープがシステムレベルで管理されるので、必然的に責務が明確になり、レイヤー間の疎結合が保たれます。
差分ビルドの有効活用
Xcodeにはコード変更時、変更箇所とその依存箇所のみが再ビルドされる差分ビルド(Incremental build)という仕組みがありますが、これがなかなか不安定です。レイヤー分割されていればそのレイヤーを超えて再ビルドされることが少なくなるので差分ビルドによる効率化が期待できます。
参照方向の制限が可能
責務が分割されるだけでなく、レイヤー同士の参照方向の制限も可能です。
先の例を使うと、以下のように一方向の参照(依存)がシステムレベルでの制限が可能です。
- View -> Core -> Infra の依存はOK
- Infra -> Core -> View の依存はNG
【デメリット】
ライブラリ管理が多少複雑化
レイヤー分割する=プロジェクトのtargetが増えることになるので、SPMにしてもPodsにしてもターゲット毎にライブラリ管理する必要があります。ただこれは、「このレイヤーでしかこのライブラリは使わない」的な責務明確化にもなるので、メリットと表裏一体かなとも思えます。
アクセススコープの刷新
プレーンなプロジェクトにEmbedded Frameworkを導入する際にこの課題があります。
レイヤー分割してないメインターゲット1つだけのプロジェクトでは、アクセススコープは一番広くてinternal
(もしくは何も指定しない)で事足りました。レイヤーが分割されることにより、別レイヤーから参照される変数・関数・クラス等全てにpublic
スコープを明記する必要があります。(特にstruct等で省略されることが多いinitへのpublic
付与が忘れがちになるでしょう)またこれまでメインだったinternal
は「同一レイヤー内からの参照」となるので、チーム開発においてのレビューやコーディング時の意識の更新が必要です。
導入手順
「"MyApp" というプロジェクトに "MyAppCore" というフレームワーク(Target)を追加する」という例で説明します。
- Targetを新規追加
- メインターゲットに1のフレームワークを追加
- 新Target向けに各種ライブラリ管理を更新
- コーディング
※ 環境:Xcode 13.4.0
1. Targetを新規追加
"Framework"と検索 -> Frameworkを選択 -> Next
"Product Name" を入力 -> Finish
(任意)対象のターゲットに UnitTest や README を付与できます。
メインターゲットに1のフレームワークを追加
メインターゲット -> General
-> Framework, Libraries, and Embedded Content
-> 1で作ったフレームワークがあることを確認
フレームワークが自動で紐づいてない場合は「+」ボタンから追加可能
新Target向けに各種ライブラリ管理を更新
【Cocoapods】
Podfileに新Target向けのセクションを追記。
以降は対象ターゲットに対して個別にPodsを追加することになります。
use_frameworks!
target 'MyApp' do
...
end
+ target 'MyAppCore' do
+
+ end
プロジェクトが大きくなり、ターゲット間でPodsライブラリを共有したいなど、より応用的な管理もabstract_target
を使えば可能です。
use_frameworks!
- target 'MyApp' do
+ abstract_target 'MyApp' do
pod 'SomeLibrary'
+ #新ターゲット名をネストして書くだけで'SomeLibrary'が利用可能
+ target 'MyAppCore'
+ #ターゲットへ限定的にライブラリを導入する場合は更にネストさせる
+ target 'MyAppTest' do
+ pod 'Quick'
+ end
end
【Carthage】
※更新予定...🙏
コーディング
デメリットの項でも触れましたが、特にアクセススコープには注意が必要です。
// "MyAppp" レイヤーから参照可能にするために public を指定
public class MyAppPresenter {
// イニシャライザも public にしないとインスタンス作れないので注意
public init() { ... }
// 変数や関数も他レイヤーからの参照予定なら public に
public var someProperty: Property
public func someFonction() { ... }
}
別レイヤーを参照するときに import
を明記します
import SwiftUI
import MyAppCore // 💡import を明記
struct MyAppView: View {
let presenter = MyAppPresenter()
var body: some View {
presenter.someFonction()
...
}
}
つまずいた点
SARAHに導入する際につまずいたところを一つ紹介します。多分かなりニッチです。
前提としてSARAHではアプリの社内配布に Firebase - App Distribution を使っています。
App Distributionはアップロードしたビルド一つのタイトルが「{ビルドバージョン} ({ビルド番号})」というフォーマットに限定されています。
そのため特にビルド番号被りを防ぐためにアーカイブ直前に、fastlane の set_build_number_repository を利用して、ビルド番号を最新のコミットハッシュに書き換えていました。
lane :distribute do |options|
set_build_number_repository #ビルド番号をコミットハッシュに書き換え
gym
emd
また、Embedded Frameworkでターゲットを追加すると、以下のようなヘッダーファイルがデフォで生成されます。
#import <Foundation/Foundation.h>
//! Project version number for MyAppCore.
FOUNDATION_EXPORT double MyAppCoreVersionNumber;
//! Project version string for MyAppCore.
FOUNDATION_EXPORT const unsigned char MyAppCoreVersionString[];
アーカイブ時にこの MyAppCoreVersionNumber
にビルド番号が代入されるのですが、型は double であるのに対し、 fastlane の set_build_number_repositoryでビルド番号を 文字列 のコミットハッシュに書き換えることでタイプ不一致でアーカイブがエラー終了しました↓↓↓
▸ Copying MyAppCore.h
▸ Compiling MyAppCore_vers.c
❌ /Users/yuki/Library/Developer/Xcode/DerivedData/MyApp-fucaebxtmdpezwbjbhutgskamtpu/Build/Intermediates.noindex/ArchiveIntermediates/MyApp (Production)/IntermediateBuildFilesPath/MyApp.build/Release-iphoneos/MyAppCore.build/DerivedSources/MyAppCore_vers.c:5:76: invalid digit 'c' in decimal constant
const double MyAppCoreVersionNumber __attribute__ ((used)) = (double)25c06eac.;
^
▸ Touching Pods_NotificationServiceExtension.framework (in target 'Pods-NotificationServiceExtension' from project 'Pods')
** ARCHIVE FAILED **
The following build commands failed:
CompileC /Users/yuki/Library/Developer/Xcode/DerivedData/MyApp-fucaebxtmdpezwbjbhutgskamtpu/Build/Intermediates.noindex/ArchiveIntermediates/MyApp\ (Production)/IntermediateBuildFilesPath/MyApp.build/Release-iphoneos/MyAppCore.build/Objects-normal/arm64/MyAppCore_vers.o /Users/yuki/Library/Developer/Xcode/DerivedData/MyApp-fucaebxtmdpezwbjbhutgskamtpu/Build/Intermediates.noindex/ArchiveIntermediates/MyApp\ (Production)/IntermediateBuildFilesPath/MyApp.build/Release-iphoneos/MyAppCore.build/DerivedSources/MyAppCore_vers.c normal arm64 c com.apple.compilers.llvm.clang.1_0.compiler (in target 'MyAppCore' from project 'MyApp')
(1 failure)
[16:43:24]: Exit status: 65
[16:43:24]:
...
ワークアラウンド
set_build_number_repository
の利用をやめ、アーカイブ時点のUNIXTIMEからコミット番号を独自に生成することにしました。
+ # ビルド番号をUNIXTIMEを元にした整数に書き換える
+ def set_build_number_from_timestamp
+ # 現在時のUNIXTIMEの末尾7桁をビルド番号としてセットする
+ now = Time.new.to_i.to_s
+ sliced = now.slice(-7, 7).to_i
+ increment_build_number(
+ build_number: sliced
+ )
+ puts "Set build number to: #{sliced}"
+ end
lane :distribute do |options|
- set_build_number_repository #ビルド番号をコミットハッシュに書き換え
+ set_build_number_from_timestamp #ビルド番号をUNIXTIME末尾7桁に書き換え
gym
emd
※ 末尾7桁にスライスしてるのは、8桁以上で指定すると malformed 64-bit a.b.c.d.e version number
エラーになるためです。深く調べてませんがでかい数値は入れられないようです。
今後期待される効果
まだSwiftUIへの移行は道半ばですが、やはり差分ビルドの高速化が期待できます。
- SwiftUIのPreviewの効率化
- 既存(UIKit)コード改修時の鈍化の防止
まとめ
日々一つひとつ課題を解決していく中で、Embedded Framework導入がありました。SwiftUI層のフレームワーク化を行いましたが、既存部分のレイヤー分割も視野に入れています。おそらく既存リファクタは新規導入よりも多くの課題が予想されますが、SARAH開発の特徴でもあるスピードを更に高めるためにも、今後も一つずつチャレンジしていこうと思います。
エンジニア募集中!
SARAHでは一皿に特化したごはん情報の投稿・配信・収集・解析するサービスを、toC、toBと多角的に展開しています。
そんなSARAHを一緒に爆進してくれるメンバーを募集中です!興味のある方はぜひ↓の採用窓口からカジュアルにお話を聞きにきてください!
皆さんと一緒に働けるのを楽しみにしています!