TCA1.7(Observable Architecture)へのマイグレーションで得た知見を共有します

ソーシャル経済メディア「NewsPicks」でiOSエンジニアをしている金子です。

WWDC23でObservationフレームワークが発表されてからすぐ、XのPoint-Freeアカウントより以下の投稿がありました。

「ViewStoreが消えるだと...!?めちゃシンプルになるじゃないか...!」とワクワクしたのを覚えています。

そこから約8ヶ月後の2024年1月末に、Observationフレームワークを取り入れ進化を遂げたTCAのバージョン1.7.0がリリースされました。

Observationフレームワークは本来であればiOS17以降から使用可能となりますが、Point-FreeはObservationフレームワークをバックポートしてiOS16以下でも使用できるようにしてしまいました...!つまり、誰でもすぐにObservationフレームワークの機能が使えるのです。

NewsPicks iOSアプリでも最新機能をいち早く取り入れるべく、1.7.0リリース直後からマイグレーションを進め、先日主要な画面に対して一通りの作業を完了させました。

公式ドキュメントにマイグレーション手順が詳しく掲載されているため、基本的にはこの手順通りやれば済みますが、いくつかハマったこと、考慮したことなどがあったので、知見として共有したいと思います。

マイグレーション手順

公式のマイグレーションガイドに詳しい手順が掲載されているので、この通りにマイグレーションを実施します。

弊社のiOSメンバーがマイグレーションガイドの内容に補足を加えて日本語で解説した記事をQiitaに公開してくれているので、こちらも是非ご参照ください。

qiita.com

StateからViewStateへの変換コードをどこに持たせるか?

TCA1.7以前では、ビューが直接参照しないStateのプロパティが更新されたときにビューが再計算されることを防ぐ目的で、ViewStateを導入することが推奨されていました。

struct State {
  var firstName: String
  var lastName: String
  var followerCount: Int  // ビューは参照しない
}

struct FeatureView: View {
  let store: StoreOf<Feature>

  struct ViewState: Equatable {
    let fullName: String

    init(state: Feature.State) {
      self.fullName = "\(state.firstName) \(state.lastName)"
    }
  }

  var body: some View {
    WithViewStore(self.store, observe: ViewState.init) { viewStore in
      // ...
      Text(viewStore.fullName)
    }
  }
}

TCA1.7からはObservationの仕組みにより、ビューが直接参照していない状態が更新されてもビューは再計算されなくなり、その結果ViewStateのような仕組みを導入する必要がなくなりました。

では、上記のコードのように、firstNamelastNameからfullNameに変換するようなコードはどこに書けばよいのでしょうか?

マイグレーションガイドでもこの点について言及があり、以下のようにStateのcomputed propertiesとして実装することができると記載されています。

struct State {
  // ...
  
  var fullName: String {
    "\(self.firstName) \(self.lastName)"
  }
}

しかし、ビューだけで参照するプロパティをReducer側に持たせるのは少し違和感があります。

こういったプロパティが増えていくと、Reducerの可読性も落ちていきます。

どうするのが良いか悩んでいたところ、公式サンプルのTicTacToeで良い例を見つけました。

TicTacToeではReducerとビューのファイルが分かれていて、ビューのファイルにStateのエクステンションが実装されています。

先ほどのコードをこのパターンにあてはめると以下のようになります。

extension Feature.State {
  fileprivate var fullName: String {
    "\(self.firstName) \(self.lastName)"
  }
}

fileprivateとすることで、fullNameにアクセスできるのはビューのファイルのコードだけです。

Reducer側にビュー用のプロパティが出てこないので、コードをスッキリさせることができます。

弊社でもReducerとビューは別々のファイルに実装しているので、このパターンを採用しました。

WithPerceptionTrackingに関する注意

アプリがiOS16以下をサポートしている場合、ビューをWithPerceptionTrackingでラップする必要があります。

WithPerceptionTrackingでラップしていないとiOS16以下の環境ではStateの更新をトラッキングできないため、実装漏れに注意しなければなりません(※iOS17の端末でアプリを動作させると実装し忘れても問題なく動作してしまうので気付けない)。

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    WithPerceptionTracking {
      Text(store.text)
    }
}

もしWithPerceptionTrackingを実装し忘れるとXcodeコンソールにランタイムワーニングが表示されるので、これによって実装漏れに気づくことができるようにはなっています。

ところが、GeomeryReaderを含むビューにおいて、実装の仕方によってはワーニングが表示されない問題があることがわかりました。

GeometryReaderはObservation backportのドキュメントで紹介されている、いわゆるLazyなクロージャを持つビューで、クロージャ内でもう一度WithPerceptionTrackingを実装する必要があります。

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    WithPerceptionTracking {
      // ...
      GeometryReader { geometry in
        WithPerceptionTracking {  // ここにも必要
          // ...
          Text(store.text)
        }
      }
    }
}

通常であればGeometryReaderのクロージャ内でWithPerceptionTrackingの実装漏れがあればワーニングが表示されるのですが、以下のようにビューをcomputed propertiesとして切り出すとワーニングが表示されなくなってしまいます。

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    WithPerceptionTracking {
      // ...
      content
    }
  }

  var content: some View {
    GeometryReader { geometry in
      // WithPerceptionTracking {  // ここの実装が漏れていてもワーニングが表示されない
        // ...
        Text(store.text)
      // }
    }
  }
}

弊社ではこのように可読性を高める目的でビューをcomputed propertiesに細かく切り出すやり方をよくやるのですが、その結果WithPerceptionTrackingの実装漏れに気づかずにリリースしてしまい、思わぬバグを発生させてしまいました。

ワーニングが表示されない件についてライブラリ側として認識しているかどうか、Github Discussionsに投稿して回答を待っているところです。

github.com

ワーニングが出るパターン、出ないパターンをサンプルコードとして上げていますので、よければこちらも参照してみてください。

github.com

UIKitのビューでobserve(_:)を使うときの注意

これまではUIKitのビューでStateの更新を検知したいときはStore.publisherまたはViewStore.publisherを使用していました。

func viewDidLoad() {
  super.viewDidLoad()

  store.publisher.count
    .sink { [weak self] in self?.countLabel.text = "\($0)" }
    .store(in: &cancellables)
}

TCA1.7からはNSObjectのエクステンションとして実装されたobserve(_:)メソッドを使うことができます。

func viewDidLoad() {
  super.viewDidLoad()

  observe { [weak self] in 
    guard let self else { return }
    self.countLabel.text = "\(self.store.count)"
  }
}

observe(_:)メソッドの仕組みについては以下のQiitaの記事で詳しく解説しているので、是非ご参照ください。

qiita.com


ここではStore.publisher/ViewStore.publisherからobserve(_:)メソッドに移行するにあたり注意すべき点について説明します。

Stateが以下のように実装されているとします。

@ObservableState
struct State: Equatable {
  var count = 0

  var exceedsThreshold: Bool {
    count > 100
  }
}

そして、ビューでcountexceedsThresholdをトラッキングします。

func viewDidLoad() {
  super.viewDidLoad()

  observe { [weak self] in
    guard let self else { return }
    print("count: \(store.count)")  // [1]
  }

  observe { [weak self] in
    guard let self else { return }
    print("exceedsThreshold: \(store.exceedsThreshold)")  // [2]
  }
}

例えば画面のボタンをタップして、State.countの値が1にインクリメントされるとします。

このとき、トラッキングしているcountの値が変わるので、[1]が実行されることは想定通りでしょう。

一方、[2]についてはどうでしょう?count > 100の結果は最初と変わらずfalseのままなので、今までStore.publisher/ViewStore.publisherを使っていた感覚からすると実行されないように思いますが、実は実行されてしまいます。

以下のようにStore.publisherをサブスクライブする場合は、Storeの内部でPublisher.removeDuplicates()が実行されていて、重複する値は排除されていました。

store.publisher.exceedsThreshold
  .sink { [weak self] in
    print("exceedsThreshold: \($0)")
  }
  .store(in: &cancellables)

observe(_:)メソッドの場合、トラッキングしているcomputed propertiesが参照しているStateのプロパティが更新されると反応してしまうようです。


最初この挙動を理解しておらず、observe(_:)メソッドのクロージャが無駄に何度も実行されて困ったことがありました。

NewsPicks iOSアプリではフィード系の画面をUITabeView/UICollectionView + DiffableDataSourceで実装していて、TCA1.7より前はデータソースを更新する処理を以下のように実装していました。

struct ViewState: Equatable {
  let sections: OrderedDictionary<NewsFeedSectionId, [NewsFeedItemId]>

  init(state: NewsFeedReducer.State) {
    var dict: OrderedDictionary<NewsFeedSectionId, [NewsFeedItemId]> = [:]
    state.sections.forEach {
      dict[$0.id] = $0.itemIds
    }
    self.sections = dict
  }
}

func viewDidLoad() {
  super.viewDidLoad()

  viewStore.publisher.sections
    .sink { [weak self] sections in
      guard let self else { return }
      var snapshot = NSDiffableDataSourceSnapshot<NewsFeedSectionId, NewsFeedItemId>()
      snapshot.appendSections(sections.keys.map { $0 })
      for section in sections {
        snapshot.appendItems(section.value, toSection: section.key)
      }
      dataSource.apply(snapshot, animatingDifferences: false)
    }
    .store(in: &cancellables) 
}

これをobserve(_:)メソッドを使う方式に移行するとこうなります。

func viewDidLoad() {
  super.viewDidLoad()

  observe { [weak self] in
    guard let self else { return }
    let sections = store.orderedSectionDict
    var snapshot = NSDiffableDataSourceSnapshot<NewsFeedSectionId, NewsFeedItemId>()
    snapshot.appendSections(sections.keys.map { $0 })
    for section in sections {
        snapshot.appendItems(section.value, toSection: section.key)
    }
    dataSource.apply(snapshot, animatingDifferences: false)
  }
}

extension NewsFeedReducer.State {
  fileprivate var orderedSectionDict: OrderedDictionary<NewsFeedSectionId, [NewsFeedItemId]> {
    var dict: OrderedDictionary<NewsFeedSectionId, [NewsFeedItemId]> = [:]
    sections.forEach {
        dict[$0.id] = $0.itemIds
    }
    return dict
  }
}

これで良さそうに見えますが、State.orderedSectionDictState.sectionsを参照しているので、State.sections内のいかなるプロパティの更新に対しても反応してしまい、その結果dataSource.apply(_:animatingDifferences:)が無駄に何度も実行され、スクロールがカクつくなどの影響が出てしまいました。

これを回避する公式なやり方は見つけられいませんが、以下のようにすることで対応しました。

observe(_:)メソッドのクロージャの外側に更新前の値を保持する変数を宣言しておき、クロージャ内で更新前後の値を比較するようにします。そして、差分があったときだけデータソースを更新する処理が実行されるようにします。

このようにすることで、クロージャ内の処理が無駄に何度も実行されることを回避することができます。

func viewDidLoad() {
  super.viewDidLoad()

  // 更新前の値を保持する変数
  var sections: OrderedDictionary<NewsFeedSectionId, [NewsFeedItemId]> = [:]

  observe { [weak self] in
    guard let self else { return }

    // 更新前後で値を比較
    guard sections != store.orderedSectionDict else {
        return
    }
    sections = store.orderedSectionDict

    var snapshot = NSDiffableDataSourceSnapshot<NewsFeedSectionId, NewsFeedItemId>()
    snapshot.appendSections(sections.keys.map { $0 })
    for section in sections {
        snapshot.appendItems(section.value, toSection: section.key)
    }
    dataSource.apply(snapshot, animatingDifferences: false)
  }
}

TCAのイベントをやります!

最後にイベントの宣伝です!

2024/3/18(月) 19:30より、弊社主催のTCAイベントを開催します!

uzabase-tech.connpass.com

TCAに特化したイベントというのはなかなか珍しいのではないでしょうか?

すでにLT枠は埋まってしまっていますが、現地参加可能な枠が空いています!(オンライン枠もあります)

TCA仲間を作るまたとないチャンスですので、是非是非イベント会場まで来てください!

すでにTCAをバリバリやっている人、これからTCAをやっていこうとしている人、興味はあるけどまだ触れていない人、どなたでもご参加いただけますので、お気軽に参加登録お願いします!

Page top