Mobile Act ONLINE #6 | uber/needleを用いたモジュール間の画面遷移とDI #MobileAct
og:image
https://gyazo.com/6409fa2d11e326388a8e0a44450f38be
自己紹介 ikesyo.icon
いけしょー/池田 翔
マンガアプリチーム
スマートフォンアプリエンジニア(iOS/Android/React Native)
趣味はOSS活動です
最近は触れていません……
かつてはHimotoki/Carthage/ReactiveSwiftなども
本題
今日はマルチモジュール構成のiOSアプリでの画面遷移とDIの話をします
とあるアプリでのマルチモジュール化での設計事例
引き合いとしてCookpadさんの事例
マルチモジュール構成での画面遷移
マルチモジュール構成
ここでは大まかに機能単位でモジュールが分かれているのをイメージしてください
Core:全Featureが依存
FeatureA
FeatureB
FeatureC
AppがFeatureA/FeatureB/FeatureCに依存
Featureモジュール間はお互いに依存しない
循環参照を避けるため
FeatureAの画面からFeatureBの画面に遷移するにはどうしたらいい?
https://gyazo.com/8dfb367fbc7a6aa088af92535b5b9127
https://gyazo.com/87a3e6c3cd94e773690ae461d651db6e
Cookpadさんの事例では
基底のモジュールとなるCoreモジュールにEnvironmentというprotocolを用意
Environmentにあらゆる依存が列挙される
code:Environment.swift
import Foundation
import UIKit
public protocol Environment {
var client: ServiceClient { get }
var pvLogger: PVLogger { get }
var userFeatures: UserFeatures { get }
var activityLogger: ActivityLogger { get }
// ...
}
モジュールをまたぐ画面遷移(FeatureAからFeatureBの画面に遷移したい)にはDescriptorとResolverという仕組みが用いられている
Descriptorはある実装を示すマーカー(※Coreに定義し、どのFeatureからも参照可能)
EnvironmentにDescriptorを渡して、型消去された実態をもらってく る仕組み = Resolver
code:swift
public protocol Environment {
...
func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor) ->
Descriptor.Output
}
public struct RecipeDetailsDescriptor: TypedDescriptor {
public typealias Output = UIViewController
public var recipeID: Int64
public init(recipeID: Int64) {
self.recipeID = recipeID
}
}
final class CookpadEnvironment: Environment {
public func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor)
-> Descriptor.Output {
switch descriptor {
case let d as ViewDescriptor.RecipeDetailsDescriptor:
return RecipeDetailsViewBuilder.build(with: d, environment: self)
as! Descriptor.Output
}
}
}
我々の場合
やりたかったこと
各Featureの依存性はFeature毎に定義したい
分割統治
ある機能がどんな依存を使っているかすぐ分かる
依存解決用のコードを自動生成したい
AndroidのDaggerのように
ここで uber/RIBs と uber/needle に着目した
RIBs is Uber’s cross-platform architecture framework. This framework is designed for large mobile applications that contain many nested states.
特にBuilderとComponent
あらゆる画面のBuilderはCoreモジュールで定義する
遷移元の画面は遷移先の画面用のBuilderを依存として受け取る
Builderを実行して画面のインスタンス(View/ViewController)を得て表示する
機能毎に必要な依存性をリストアップ・実行時に解決する
遷移先の画面用のBuilderもここに依存として定義する
RIBsアーキテクチャそのものを採用したわけではない
Needle is a dependency injection (DI) system for Swift. Unlike other DI frameworks, such as Cleanse, Swinject, Needle encourages hierarchical DI structure and utilizes code generation to ensure compile-time safety. This allows us to develop our apps and make code changes with confidence. If it compiles, it works. In this aspect, Needle is more similar to Dagger for the JVM.
AndroidのDaggerと発想が近い
これを使ってみよう
コード例を見てみましょう
FeatureAのFoo画面からFeatureBのHoge画面に遷移する
アプリの起動時はFeatureAのFoo画面を表示する
Coreモジュール
code:Dependencies.swift
public protocol APIClient { ... }
public protocol Logger { ... }
code:Builder.swift
public protocol Buildable: AnyObject {} // あまり意味はない
open class Builder<Dependency>: Buildable {
public let dependency: Dependency
public init(dependency: Dependency) {
self.dependency = dependency
}
}
code:Feature.swift
public protocol Feature {} // あまり意味はない
code:FeatureA.swift
public protocol FooBuildable: Buildable {
func build() -> UIViewController
}
public protocol FeatureA: Feature {
func fooBuilder() -> FooBuildable
}
code:FeatureB.swift
public protocol HogeBuildable: Buildable {
func build(id: String) -> UIViewController
}
public protocol FeatureB: Feature {
func hogeBuilder() -> HogeBuildable
}
このFeatureにはこういう画面があって、というのはCoreモジュール上で定義されるので全員が分かる
FeatureA
code:FeatureA.swift
import Core
import NeedleFoundation
// DependencyはNeedleFoundationから
public protocol FeatureADependency: Dependency {
var apiClient: APIClient { get }
var logger: Logger { get }
// FeatureBのHoge画面への遷移用
var hogeBuilder: HogeBuildable { get }
}
class FooBuilder: Builder<FeatureADependency>, FooBuildable {
func build() -> UIViewController {
FooViewController(
apiClient: dependency.apiClient,
logger: dependency.logger,
hogeBuilder: dependency.hogeBuilder
)
}
}
class FooViewController: UIViewController {
...
func openHogeVC() {
// 別モジュールの実装をBuilderで取得する。
let hogeVC = hogeBuilder.build(id: "ABC")
present(hogeVC, animated: true)
}
}
// ComponentはNeedleFoundationから
public class FeatureAComponent: Component<FeatureADependency>, FeatureA {
func fooBuilder() -> FooBuildable { FooBuilder(dependency: dependency) }
}
Feature毎にDependency/Builder/Componentを用意する
ComponentからBuilderを返す
BuilderにComponentの持っている依存を渡す
FeatureB
code:FeatureB.swift
import Core
import NeedleFoundation
public protocol FeatureBDependency: Dependency {
var logger: Logger { get }
}
class HogeBuilder: Builder<FeatureBDependency>, HogeBuildable {
func build(id: String) -> UIViewController {
HogeViewController(id: id, logger: dependency.logger)
}
}
class HogeViewController: UIViewController { ... }
public class FeatureBComponent: Component<FeatureBDependency>, FeatureB {
func hogeBuilder() -> HogeBuildable { HogeBuilder(dependency: dependency) }
}
App
ビルドフェーズでコード生成
code:bash
# needleコマンドはHomebrewで入れる。
needle generate App/needle.generated.swift
code:RootComponent.swift
import Core
import FeatureA
import FeatureB
import NeedleFoundation
// アプリケーションのルートのComponentはBootstrapComponentを継承する。
final class RootComponent: BootstrapComponent {
// ここで各依存の実体を注入する。
var apiClient: APIClient { APIClientImpl(...) }
var logger: Logger { LoggerImpl(...) }
// 子コンポーネント
var featureAComponent: FeatureAComponent { FeatureAComponent(parent: self) }
var featureBComponent: FeatureBComponent { FeatureBComponent(parent: self) }
// FeatureAComponentのFeatureADependency用にHogeBuildableを親コンポーネントで提供する。
var hogeBuilder: HogeBuildable { featureBComponent.hogeBuilder() }
}
code:AppDelegate.swift
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
private(set) var rootComponent: RootComponent!
// 自動生成された関数。
// Dependency protocolの実装クラスがこれで解決されるようになる。
registerProviderFactories()
rootComponent = RootComponent()
return true
}
...
}
code:SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
self.window = window
let rootComponent = (UIApplication.shared.delegate as! AppDelegate).rootComponent
let featureAComponent = rootComponent.featureAComponent
let fooBuilder = featureAComponent.fooBuilder()
let viewContrroller = fooBuilder.build()
window.rootViewController = viewController
window.makeKeyAndVisible()
}
}
めでたしめでたし
機能毎の依存の切り分け
この例だとFooBuilderがFeatureADependencyに依存しているが、BarBuilderには別の依存が、となれば
code:swift
class FooBuilder: Builder<FooDependency>, FooBuildable { ... }
class BarBuilder: Builder<BarDependency>, BarBuildable { ... }
protocol FeatureADependency: Dependency, FooDependency, BarDependency {}
class FeatureAComponent: Component<FeatureADependency>, FeatureA { ... }
のようにすることもできそう
uber/needleによるコード生成
アプリケーションで実体を注入していない・解決できない依存があればコード生成がエラーになる
Compile time safety
発表はここまで
hr.icon
採用情報
漫画が好き
宣言的UI
GraphQL
マルチモジュール化
などに興味ある方はご連絡お待ちしています!!
https://gyazo.com/ab1f9fdf81cf800c9f714022f122f722
https://res.cloudinary.com/meety-inc/image/upload/v1631778987/meety/prod/ogp_images/di6gswpwrel5l91vsrl9.png https://meety.net/matches/JMMNULLUcCuN
hr.icon
😆THANK YOU❗️💖