LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


LINE Creators Studioに広がる美しい型の世界(前篇)

こんにちは、tarunonです。 この半年間はLINE Creators StudioのiOS appを担当していました。一番最初のコードベースを作るところから担当でき、そして前回のLINE Engineering Blogでの知見もあったので、「コンパイラが証明できる世界」というものを目標に開発に取り組むことができました。 中でも、特に上手く行って他のプロジェクトにおいても同様に有用であろうものを2つ、紹介致します。前篇はInterface Builderについてtarunonが、 後篇はAPI Clientについて@ukitakaがお話します。

LINE Creators Studioで使っているInterface Builderの環境と同様(少し進歩しています)のものは、こちらに切り出していて、すぐにPlaygroundで遊べるようになっています。→ Instantiate

もしかしたらスター点けてくれてる方もいらっしゃるかも? Instantiate自体はLINE BLOGの頃からちょこちょこ書いていて、LINE Creators Studioの開発中も並行して書き進めていました。 コードを見たほうが楽しい方はぜひ上記リポジトリもご参照下さい。
LINE Creators StudioのView/ViewControllerは以下のコードでインスタンス化することが出来ます。これは全てのView/ViewControllerで共通しています。(※Instantiateとは若干の差異があります。)

let view: View = View.instantiate() // View.Dependency = Voidの場合
let viewController: ViewController = ViewController.instantiate(with: dependency) // dependency: ViewController.Dependency
let cell: Cell = tableView.dequeueReusableCell(Cell.self, for: indexPath, with: dependency) // dependency: Cell.Dependency 

左辺に型を明記していますが、実際には右辺の型で推論されるので省略可能です。 とても使い勝手が良いように見えますが、使い勝手だけでなく、機能や型安全の面からみても良い点が沢山あります。 詳細について記述していきます。

コンパイラが証明できる範囲を考える

LINE Creators Studioではinstantiate関数を使うことでView/ViewControllerのインスタンスを、右辺の型で生成することが可能です。 同様のアプローチは、View/ViewControllerにnib/storyboardを静的に紐付けていれば実現可能で、既存のアプローチも存在しています。

View/ViewControllerにnib/storyboardを静的に紐付けるアプローチは強力で、ただ楽なだけではなく、コンパイラがView/ViewControllerとnib/storyboardの関係性について証明できるようになります。(勿論、静的に紐付けたものが間違っている可能性はあるのでテストを書いたり規約を強く設定するなども必要です) LINE Creators Studioでは、そこからさらに一歩踏み込んで、View/ViewControllerが持つべきDependencyについても、型として記述するようになっています。

ViewController.instantiate(with: dependency)

instantiate関数に引数を設定することが出来る、つまり、Constructor Injectionです。 View/ViewControllerを作った場合、ほぼ必ず幾つかのDependencyが必要になります。環境変数ならば、DIコンテナなどを用いるのも良い手段だとは思いますが、大半のView/ViewControllerは何かしらのパラメータを有しているはずです。 LINE Creators Studioではこのようなパラメータは、instantiate関数の引数として渡しています。 インスタンス化とDependencyを与える箇所が分かれていると、コンパイル時にそのチェックをすることは不可能です。

let view = View.instantiate()
view.inject(dependency) // 消してもコンパイル出来てしまう…

コンパイルの問題だけではありません。プロジェクトに途中から参加したメンバーは、そのView/ViewControllerを生成した後で、パラメータを追加する関数、変数は何なのか知らなければいけない。 インスタンスを生成する関数が、引数としてこれらのパラメータを持っていれば、その存在をコンパイル時に証明することが出来るようになります。 途中から参加したメンバーも、インスタンス生成に必要なパラメータが一見してわかります。

Constructor InjectionとInterface Builderの利用を両立する

前提として、LINE Creators Studioで採用したInterface Builder周りの基本的な規約を列挙しておきます。必ずしもこの通りでなければ実現出来ないわけではないですが、LINE Creators Studioの開発環境はかなり理想に近いものであったと感じています。

  1. Viewはxib、ViewControllerはstoryboardを採用する。
  2. 1storyboard - 1ViewController、Segueは採用しない。
  3. xib/storyboard name, reuse identifierとClass nameを同一に設定する。
  4. View/ViewControllerをfinalクラスで定義する。

LINE Creators Studioに含まれるprotocolは、ざっと見るとこのような構造となっています。

最上位にあるInjectableは、Dependencyを渡すための関数、injectを定義しています。

protocol Injectable {
  associatedtype Dependency
  func inject(_ dependency: Dependency)
}

InstantiatableDependencyを引数に取ってインスタンスを生成する関数、instantiateを定義しています。(Instantiateではinitで定義しています。final classとして定義する必要がなくなるというメリットがあります)

public protocol Instantiatable: Injectable {
  static func instantiate(with dependency: Dependency) -> Self
}

そして、NibInstantiatableStoryboardInstantiatableに、Nib/Storyboardからインスタンスを取り出し、injectをコールするinstantiateのデフォルト実装が記述されています。
NibInstantiatable/StoryboardInstantiatableを実装したView/ViewControllerは、Dependencyの型とinjectの実装を記述することで、instantiateを使うことが出来るようになります。

ReusableInstantiatableとは別の系列で実装されていて、UITableView/UICollectionViewに、Reusable型を引数に取る関数、dequeueReusableCell(_: Parent, for: IndexPath with: Dependency)が実装されています(Instantiateでは、Reusable.dequeue(from: Parent, for: IndexPath, with: Dependency)として定義されています。こちらのほうがコード補完が優秀です)

extension UITableView {
  func dequeueReusableCell<C: Reusable>(_ type: C.Type=C.self, indexPath: IndexPath, dependency: C.Dependency) -> C where C: UITableViewCell
}

こちらも、UITableViewCell/UICollectionViewCellにinjectを定義するだけでUITableView.dequeueReusableCell(_: Parent, for: IndexPath with: Dependency)が使えるようになります。

LINE Creators Studioでは、Interface Builderの規約として 3. xib/storyboard name, reusable identifierとClass nameを同一に設定する。 というものを持っていました。つまり、Reusable, NibType, StoryboardTypeの実装にデフォルト実装を持たせることができます。 残りの実装は全てProtocol準拠のデフォルト実装です。したがって、View/ViewControllerに実際に実装する必要があるのはinject関数のみ、ということになります。 inject関数を実装するだけで、nib/storyboardからインスタンスを生成しinjectの呼び出しまで保証される環境が手に入りました、使い勝手は最高です。

RxSwiftでもっと楽をする

楽をする…楽をしたかった… ここから先は、残念ながらLINE Creators Studioのリリースではできなかったこと、つまり僕の妄想の垂れ流しになります。 LINE Creators StudioではRxSwiftをフル活用しています。
View/ViewControllerにはSubjectベースのViewModelが存在していて、これはLazyVariableという名前のReplaySubject(1)のNo Error Wrapperであり(言い換えると、初期値の必要のないVariable)、また、TableView/CollectionViewはRxDataSourcesを用いています。

実際のところ、View/ViewControllerのinject関数の実装は、ViewModelへの値のバインディングでしかない、というのが実のところで、であればViewModelの存在をprotocolとして定義すれば、injectの実装が自明となり、デフォルト実装を作って個別の実装は省略することが可能でした。

protocol RxViewProtocol: Injectable {
 associatedtype ViewModel: Injectable
  var viewModel: ViewModel { get }
}
extension RxViewProtocol where ViewModel.Dependency == Dependency 
  {
  func inject(_ dependency: Dependency) {
    viewModel.inject(dependency)
  }
}

DataSourceの生成も、protocolベースで定義することが可能です。1TableView-1Cellなら、SectionModelType.ItemがCell.Dependencyと等しければDataSourceの実装が自明になります。

func reloadDataSource<C: Reusable & UITableViewCell, S: SectionModelType>(for cellType: C)
  -> RxTableViewReloadDataSource<S>
  where S.Item == C.Dependency
{
  let dataSource = RxTableViewReloadDataSource<S>()
  dataSource.configureCell = { (_, tableView, indexPath, item) in
    return tableView.dequeueReusableCell(C.self, for: indexPath, with: item)
  }
  return dataSource
}

1TableView-nCellであっても、SectionType.Itemを複数のCell.Dependencyをassociated valueに持つenumとして定義すれば、実装が自明になります。

func reloadDataSource<C1: Reusable & UITableViewCell, C2: Reusable & UITableViewCell, S: SectionModelType>(for cellType1: C1, cellType2: C2)
  -> RxTableViewReloadDataSource<S>
  where S.Item == AnyEnum2<C1.Dependency, C2.Dependency>
{
  let dataSource = RxTableViewReloadDataSource<S>()
  dataSource.configureCell = { (_, tableView, indexPath, item) in
    switch item {
      case case1(let x): tableView.dequeueReusableCell(C1.self, for: indexPath, with: item)
      case case2(let x): tableView.dequeueReusableCell(C2.self, for: indexPath, with: item)
    }
  }
  return dataSource
}

何をして何が可能になったか整理してみましょう。

何をしたか

  1. ViewModelへのバインディングをinject関数を用いて行う、Injectableのデフォルト実装として型に定義した。
  2. Cellのdequeueをinject関数を呼び出すことを保証した、protocolベースの関数で行うように定義した。

何が可能になったか

  1. DataSource.ItemCell.Dependencyである場合、dequeueはprotocolベースの関数が使えるのでジェネリックな実装が可能。
  2. 複数Cellが必要な場合も、enumのassociatedvalueに対して型を束縛することでジェネリックな実装が可能。
  3. ジェネリックな実装は既に型で証明された関数に基づいて作られているので安全。
  4. 必要な実装は、CellにViewModelの型を定義するのみ。

…妄想だけで終わらせるのは勿体無いので、リポジトリを作って実現可能であることを示してあります。→ RxInstantiate

テストケースを拝借すると、次のようなコードが、Cell側はViewModelの型の定義のみで記述できるようになります。

dataSourceObservable // 3 case enum dataSource
   .bind(to: tableView.reloadDataSource(for: Cell1.self, Cell2.self, Cell3.self))

gyb使ってみた

これはおまけコンテンツなのですが、RxInstantiateは任意個caseを持つenumに対応出来る…わけではありません。 なので、gybを使って、コード生成をすることで、任意個のcaseにも対応できるようにしています。 gybはSwiftリポジトリに含まれているpython製のツールで、使い方については割愛しますが、感想としては、案外悪くなかったです。 Swiftはまだまだ発展途上で、これが欲しいと思ったものが実現できないことは、珍しいことではありません。しかし、それを諦めてしまうのではなく、例えばコード生成があれば解決できる、というアプローチも考えてみるのは良さそうだと感じました。

まとめ

今年始め、Swift Advent Calendarに投稿した記事から、答えと言えるところまで発展できたのでは無いでしょうか。 Protocolを定義して組み合わせることで、多くの実装が自明になる、その中でコードの安全さ、コンパイラの証明できる範囲を広げて行く。そういう楽しみ方に、Swiftでお仕事をして2年目で、ぼんやり気づいてきた次第であります。
Interface BuilderをSwiftyに使うこと、そして悩ましくなるDependencyの取扱いについて、protocolで解決する方法を提案しました。protocolの階層が綺麗に作れたので、RxSwiftや他のライブラリとの組み合わせで拡張することが容易です。その一例としてRxInstantiateを紹介しました。

UIKitを直に使うのは、辛いことが多すぎるので少しずつprotocolに切り出して綺麗な世界でSwiftを書けるようにしていきたいですね。(本音を言うとUIKitじゃなくてPure swiftの新しいUI Frameworkが欲しいところです)