needleでDI改善に取り組み始めた話
概要
ガチャを引く際にご利益がある曲を探すのがマイブームのかむいです。
REALITYのiOSチームではDIのルールを整理し、既存コードに多く存在しているBastard Injectionなコードの除去、またシングルトンな神Managerクラスに依存している問題を解決したいといった課題がありました。今回は取り組み中のDI改善について書きたいと思います。
OSSか独自DIコンテナか
iOSで推奨 or 支持されているDIシステムはAndroidほど決定的なものは見かけない印象です。Androidでは公式リファレンスで推奨されているHiltがありますが、iOSではSwinjectやCleanseなどがありつつも、そもそもOSSを利用しない独自のDIコンテナを選択するケースも多いと思います。
私も始めは独自DIコンテナを選択しました。既に一部機能では導入されていたためです。しかし利用している人が限定的で、且つその仕様まとめ・共有・フィードバックを得るなどチームへの浸透・理解を得るまでの時間が当時作れていない問題があり、実績のあるOSS選定に切り替えました。
OSSも最初はSwinjectを試したのですが、IUO(Implicitly Unwrapped Optional)を多用するため型安全ではなく、学習コストもそれなりにあったため、型安全で且つ理解しやすいものを探しました。
選ばれたのはneedleでした
今回はUberが運用しているneedleを採択をしました。OSSの選定理由としてはドキュメントの充実度や更新頻度など一般的な選定基準に沿ったものとなりますが、その他needleの持つ以下の特徴が決め手でした。
■Dagger Like
needleは階層的なDI構造を実現する際、依存解決コードをコンパイル時に自動生成する仕組みとなっています。コンパイル時のDI注入の安全性を確保できるため、自信を持ってコード変更ができます。
この仕組みはAndroidのDIシステムであるDaggerと似ており、Dagger上に構築されたHiltを普段使っているAndroidエンジニアの方にも理解しやすいと思います。
■シンプルな登場人物
needleにはComponentクラスとDependencyプロコトルの2つの仕組みでDI構造を実現します。開発者はDependencyに準拠したプロトコルを作成し、そのプロコトルに準拠したComponentクラスを作成します。親子関係のある依存性はComponent下に新たなComponentを指定する形で依存関係を定義できます。(以下README.mdより抜粋)
/// This protocol encapsulates the dependencies acquired from ancestor scopes.
protocol MyDependency: Dependency {
/// These are objects obtained from ancestor scopes, not newly introduced at this scope.
var chocolate: Food { get }
var milk: Food { get }
}
/// This class defines a new dependency scope that can acquire dependencies from ancestor scopes
/// via its dependency protocol, provide new objects on the DI graph by declaring properties,
/// and instantiate child scopes.
class MyComponent: Component<MyDependency> {
/// A new object, hotChocolate, is added to the dependency graph. Child scope(s) can then
/// acquire this via their dependency protocol(s).
var hotChocolate: Drink {
return HotChocolate(dependency.chocolate, dependency.milk)
}
/// A child scope is always instantiated by its parent(s) scope(s).
var myChildComponent: MyChildComponent {
return MyChildComponent(parent: self)
}
}
■ DI注入が安全でないものはビルドエラーで対象の変数名, 型, 階層を指摘してくれる
対象のComponentに必要となるDIの登場人物が不足、または正しく無い情報が設定されていると、コンパイル後に対象の変数をエラー文で具体的に提示してくれます。以下のような感じです。
class RootComponent: BootstrapComponent {
// DIする変数をコメントアウトすると...
// var hogeService: HogeService {
// DefaultHogeService() }
// }
// MARK: Children
var loginComponent: LoginComponent {
LoginComponent(parent: self)
}
}
protocol LoginDependency: Dependency {
var hogeService: HogeService { get }
}
class LoginComponent: Component<LoginDependency> {
var viewController: LoginViewController {
LoginViewController(hogeService: dependency.hogeServoce)
}
}
サンプルアプリと アーキテクチャ専用の勉強会
本格導入する前にneedleを使ったDIのルール、実装方法や導入マイルストーンについて議論する時間を設けました。実装方法について議論するためのアプリをneedleのSampleを参考に用意し、モブプロも行いながらフィードバックをもらいました。サンプルアプリではあるものの中身が洗練されたため、新たに加わるメンバーにもneedleの解像度を高める教材にできるかと思います。画面構成はプロダクトに寄せており、今後もneedleの使い方に変更がある場合はサンプルアプリも更新しながら導入を進めていく予定です。
議論する時間はiOSの設計をテーマとした社内勉強会「あきべん。」の時間を使って行いました。もともとiOSの社内勉強会は別であるのですが、そこではテーマが自由で個々人で話す時間に制限があるため、今回の様な1つのテーマで長く議論する時間が定期的に必要だったため早速活用しました。
(余談ですが他にもREALITYでは色々なテーマで社内勉強会が行われています。詳細は以下の記事をご覧ください)
最初にneedleを導入する箇所
最初は影響範囲の小さい、keyWindow?.rootViewControllerから画面クラスをpresentした際に上層に位置する画面から運用する予定でした。しかしRootComponentとなるクラスを上層のViewControllerから始める場合、needleの影響範囲を広げていく都度RootComponentを下層に移行していくことになり、運用当初から利用できる範囲が限定的になる問題が出てきます。
そのため最初に導入する箇所は、本来の想定である初回起動時の画面クラスに紐づいた形でRootComponentを設置する形となりました。
勿論アプリ起動時に導入することで、メスを入れるのにシビアになりがちなログイン機能や、プロダクト開発初期からあるクラスに埋め込むことへの大変さ(当初想定し得なかった動きとか画面構成の変更など)はあるのですが、ここではそれらの対応を頑張った上で得られるneedleのメリットについてまとめます。
■Singletonな神Managerクラスの依存解決を段階的に対応可
Singletonな神Managerクラスの値の除去、またはクラス自体の除去は運用が長く続くプロダクトほど骨の折れる課題ですが、needleの場合はそのクラスの値をいきなり切って捨てる必要はありません。
class RootComponent: BootstrapComponent {
// FIXME: 既存の神クラスの値を参照
var hogeService: HogeService {
KamiManager.shared.hogeService
// TODO: 神ManagerクラスからhogeServiceを削除したらコメントアウト
// DefaultHogeService()
}
// MARK: Children
var loginComponent: LoginComponent {
LoginComponent(parent: self)
}
}
needleからDIする値の参照元に神Managerクラスの値を指定することで、needleからもアクセスでき、神Managerクラスからの依存を存続させることが可能です。needleからのDIを徐々に浸透させていき、最終的にKamiManager.shared.hogeServiceを直接呼んでいるところが無くなり次第、KamiManagerからhogeServiceを削除し、needleから生成する形を取ることで段階的に神Managerクラスから依存度の強い値を除去することが出来ます。
■Bastard Injectionな値の除去
Bastard Injectionは外部から値を差し替えることが可能な点でTestabilityにおいては問題はありませんが、依存先をインターフェースに留め、結合度を下げるためにも除去していおきたいDIのアンチパタンです。
今回のルール見直しにより、新規で開発するクラスやneedleを利用する際にはBastard Injectionを行わないことをルールに追記することにしました。
needleでは依存をコンストラクターインジェクションを用いてComponentクラスでDI処理を実施していますが、その際にBastard Injectionはせず親Componentを通してDepepdencyから依存注入します。
protocol LoginDependency {
var hogeService: HogeService { get }
}
protocol LoginBuilder {
func makeViewController() -> LoginViewController
}
class LoginComponent: Component<LoginDepedency>, LoginBuilder {
func makeViewController() -> LoginViewController {
let viewModel = makeViewModel()
let instance = R.storyboard.login().instantiateInitialViewController { coder in
LogintViewController(coder: coder, viewModel: viewModel)
}!
return instance
}
private func makeViewModel() -> LoginViewModel {
DefaultLoginViewModel(usecase: makeUseCase())
}
private func makeUseCase() -> LoginUseCase {
DefaultLoginUseCase(hogeService: dependency.hogeService)
}
}
まとめ
needleはSwinjectなどの有名OSSと違い記事が少ないため、今後も新しい問題や解決方法が出てきたら、このnoteを通じて発信していきたいと思っています。
REALITYでは新しい技術の導入やプロダクトのリファクタリング, リライトが好きなエンジニアを募集しています。
ちなみにこれらの改善施策は、新規機能開発と並行でよしなに個々人で頑張ってねーではなく、きちんと改善施策のための時間が用意されています。
もし興味のある方は、meetyでカジュアルにエンジニアとお話ししてみませんか?お待ちしておりますーmm
追記
needleでモジュール間の画面遷移を実現した話も記事にしましたのでよろしければそちらも合わせてどうぞ。