Upgrade to Pro — share decks privately, control downloads, hide ads and more …

SwiftUI+TCAに挑戦!NewsPicks iOSアプリのリアーキテクチャ/re-architecture-newspicks-ios-app-with-swiftui-and-tca

SwiftUI+TCAに挑戦!NewsPicks iOSアプリのリアーキテクチャ/re-architecture-newspicks-ios-app-with-swiftui-and-tca

1da8b476058df860d83a12c496b74fff?s=128

Takehiro Kaneko

July 30, 2022
Tweet

More Decks by Takehiro Kaneko

Other Decks in Programming

Transcript

  1. SwiftUI+TCAに挑戦!
 NewsPicks iOSアプリの
 リアーキテクチャ
 2022.7.28 TechBase Vol.2
 NewsPicks iOSエンジニア /

    金子 雄大

  2. 00
 自己紹介
 ©NewsPicks Inc. All Rights Reserved. 
 金子 雄大


    NewsPicks iOSエンジニア 
 @takehilo_kaneko
 takehilo
 takehilo

  3. 1. NewsPicks iOSアプリのリアーキテクチャの背景
 2. リアーキテクチャで目指しているものと技術選定
 3. メイン画面をSwiftUI+TCAで実装、しかし...
 4. SwiftUI+UIKit+TCAの実装詳細
 00


    今日話すこと
 ©NewsPicks Inc. All Rights Reserved. 

  4. ©NewsPicks Inc. All Rights Reserved. 
 01
 NewsPicks iOSアプリの
 リアーキテクチャの背景


  5. 01
 ビジネス優先で走り続けてきた
 ©NewsPicks Inc. All Rights Reserved. 
 NewsPicksはリリースから8年以上が経過。 


    その間、何度もUIをリニューアル してきた。
 一方で、コードの保守性は置き去り にされたまま。
 iOSアプリのコードは限界に... 

  6. NewsPicksはこれから益々ビジネスを拡大していこうとしている
 このままではビジネスのスピードにコードがついていけなくなる
 そこで2021年7月より、リアーキテクチャを開始


  7. ©NewsPicks Inc. All Rights Reserved. 
 02
 リアーキテクチャで
 目指しているものと技術選定


  8. 02
 ビジネスの拡大に伴って出てきた課題
 ©NewsPicks Inc. All Rights Reserved. 
 バグが発生しやすい 


    なかなかのスパゲッティコードになってい るので、コードを深く理解している人でな いと考慮不足でバグを発生させてしまい やすい状態になっていた。 
 人が増えても生産性が 
 上がらない
 実装に明確なルールがなかったので コードの可読性、テスタビリティが低く、 途中から入ったメンバーがコードを把握 するのが難しい状態になっていた。 
 新規メンバーは現状のコードを理解する ところから始める必要があり、 それに時 間がかかるので、人が増えても生産性 がなかなか上がらないという状態だっ た。
 ビジネスのスピードに 
 追いつかない
 ビジネスの拡大と共に開発要望も爆増し ているが、保守性の低さが原因で 開発 のスピードがビジネス要求に応えられな くなってきた。

  9. 02
 目指している姿
 ©NewsPicks Inc. All Rights Reserved. 
 • 各事業チームにiOSエンジニアを配置し、事業単位で並行

    で機能開発できる体制を作りたい 
 • iOSエンジニアを増やせば生産性が上がる状態を作りたい 
 
 この体制に耐えうるアーキテクチャを作ることが現在のミッション 
 事業ごとに並行開発可能なアーキテクチャ 
 課金事業 広告事業 法人事業 新規事業 プロダクト チーム iOSチーム
  10. 02
 理想のアーキテクチャにするために採り入れていること
 ©NewsPicks Inc. All Rights Reserved. 
 レイヤー分割、
 マルチモジュール化

    
 Domain、Infrastructure、Presentationと いったレイヤーを定義し、ビジネス・プレ ゼンテーションロジックをテストしやすい 構造に変更。
 さらに、それぞれをフレームワーク化して 依存関係を強制することで、正しい構造 を長期に渡って保てるようにする。 
 実装ルールをある程度 
 強制する
 実装ルールを決めるだけでは秩序は保 てない。人数が増えれば増えるほど、秩 序を保つのは難しくなる。 
 ツールやライブラリを活用し、実装ルー ルをある程度強制することで、誰が実装 しても読みやすく保守しやすいコードに なるようにする
 UI実装の効率化
 NewsPicksでは頻繁にABテストを行って おり、UI変更の頻度が非常に多い。 
 UI実装を効率化し、フィードバックサイク ルを速く回せるようにする。 

  11. 02
 理想のアーキテクチャにするために採り入れていること
 
 ©NewsPicks Inc. All Rights Reserved. 
 レイヤー分割、


    マルチモジュール化 
 Domain、Infrastructure、Presentationと いったレイヤーを定義し、ビジネス・プレ ゼンテーションロジックをテストしやすい 構造に変更。
 さらに、それぞれをフレームワーク化して 依存関係を強制することで、正しい構造 を長期に渡って保てるようにする。 
 実装ルールをある程度 
 強制する
 実装ルールを決めるだけでは秩序は保 てない。人数が増えれば増えるほど、秩 序を保つのは難しくなる。 
 ツールやライブラリを活用し、実装ルー ルをある程度強制することで、誰が実装 しても読みやすく保守しやすいコードに なるようにする
 UI実装の効率化
 NewsPicksでは頻繁にABテストを行って おり、UI変更の頻度が非常に多い。 
 UI実装を効率化し、フィードバックサイク ルを速く回せるようにする。 
 ・レイヤードアーキテクチャ 
 ・XcodeGen
 ・TCA
 ・SwiftLint
 ・SwiftFormat
 ・SwiftUI

  12. 02
 なぜThe Composable Architectureを採用したのか?
 ©NewsPicks Inc. All Rights Reserved. 


    以前からReduxの採用を考えていた 
 iOSアプリでは複雑で多様な状態変化を管理する必要がある。 
 NewsPicksアプリのニュースフィードでは様々な種類のセルが登場し、さらに各セルでは 様々なイベント、状態変化が起きうるので、複雑なコードになりがち。 
 さらにNewsPicksではデータドリブンな開発を行っていて、アプリから 多種多様なログを送信 している。ルートのコンポーネントが複数の下位コンポーネントの状態を把握した上でログ を送る、などの要件もあり複雑さの原因となっている。 
 個人的にReSwiftを使ってReduxを導入した経験があり、 Reduxがこうした複雑さを一定解消 してくれることは知っていたので、Reduxを導入したいと思っていた。 

  13. 02
 なぜThe Composable Architectureを採用したのか?
 ©NewsPicks Inc. All Rights Reserved. 


    Reduxライク
 Reduxが分かっていれば理解しやすいアーキテクチャだったので、 個人的な学習コストが低かった 
 UIKitのサポートがある 
 現状そこまで充実しているわけではないが、UIKitでも使える。 UIKitベースのプロジェクトに導入するにあたってこれは嬉しい。 
 DIの仕組みが用意されている 
 テスタブルな構造にするにあたってDIをどう実装するかは課題とな るが、その仕組みがライブラリに備わっている 
 ユニットテストのサポートがある 
 ユニットテストを書くためのサポートがしっかりと用意されている。 TCAのREADMEでもテスタビリティを強くアピールしているように見 受けられるので安心。 
 話題のTCAでサンプル実装を行い、以下の観点で良いと感じて採用。 

  14. ©NewsPicks Inc. All Rights Reserved. 
 03
 メイン画面をSwiftUI+TCAで実装
 しかし...


  15. 03
 メイン画面のリニューアル
 ©NewsPicks Inc. All Rights Reserved. 
 SwiftUI+TCAで作り直す 


    ビジネス的な課題意識から、メイン画面をリニューアルするプロジェクトが始まった。 
 メイン画面は特に細かな仕様が多く、 既存コードの複雑性はアプリ内随一 。一番作り直したいと思って いた画面なので、この機会にSwiftUI+TCAで実装するというチャレンジをすることにした。 

  16. 03
 SwiftUI+TCAの実装で出てきた課題
 ©NewsPicks Inc. All Rights Reserved. 
 スクロールがカクつくように 


    なってしまった
 SwiftUIのListを使ってニュースフィードを実装したが、スクロール がひどくカクつくようになってしまった。 
 スペックが低い端末だとカクつきは顕著になり、とてもユーザ体 験が良いとは言えない状態に。 
 UIKitでの作り直し、ワークアラウンドの増加 で開発コストが増加
 最初SwiftUIで実装していたところがあと一歩のところで要件を満 たせないなどが判明し、UIKitをラップして作り直すということが何 度か発生した。
 また、SwiftUI-Introspectへの依存が増えていったりして今後の メンテが不安になっていった 

  17. 03
 スクロールがカクつくようになってしまった
 ©NewsPicks Inc. All Rights Reserved. 
 SwiftUIのList、TCAのForEachStore、SwitchStoreなどを使ってニュースフィードを実装し たが、スクロールがひどくカクつくようになってしまった。

    
 ソースのイメージ:
 https://github.com/pointfreeco/swift-composable-architecture/discussions/1027 
 ビューのレイアウトの仕方や、無駄な再描画が起きていることなどが原因とみて色々調査 していたものの、スムーズなスクロールを実現することができなかった。 
 原因がよくわからなかった 

  18. 03
 スクロールがカクつくようになってしまった
 ©NewsPicks Inc. All Rights Reserved. 
 SwiftUIのList、TCAのForEachStore、SwitchStoreなどを使ってニュースフィードを実装し たが、スクロールがひどくカクつくようになってしまった。

    
 ソースのイメージ:
 https://github.com/pointfreeco/swift-composable-architecture/discussions/1027 
 ビューのレイアウトの仕方や、無駄な再描画が起きていることなどが原因とみて色々調査 していたものの、スムーズなスクロールを実現することができなかった。 
 原因がよくわからなかった 
 後日検証した結果、原因が判明。 
 後のスライドで紹介します。 

  19. 03
 UIKitでの作り直しの例
 ©NewsPicks Inc. All Rights Reserved. 
 SwiftUIだと思うような画面を作れなかった 


    カルーセルをSwiftUIで実装(LazyHStack、GeometryReader、DragGestureなど)していたが、アニメー ションが不自然だったり、スクロールのパフォーマンスが悪くなるなどしたため、UICollectionViewで実装 し直した。
 その他にも、上タブを実現するSwiftUIライブラリを入れてみたがタブ切り替えが重くなるなどしてうまくい かず、UIKitベースの実装で作り直した。 

  20. 03
 SwiftUI-Introspectへの依存が増加
 ©NewsPicks Inc. All Rights Reserved. 
 Listのセパレータを消すなど最小限の利用に留めるつもりで SwiftUI-Introspectを導入したが、Listだとレイアウトがどうし

    てもうまくいかなくてUITableViewを参照するなど、Introspect に頼らざるを得ないことが徐々に増えてしまった。 
 「やむを得ずIntrospectに頼る」がチリツ モに...
 独自で実装した
 Modifier
 .introspectTableView { tableView in // セルの更新処理 if sectionIndex = ViewStore(store).reloadingSectionIndex { tableView.performBatchUpdates { tableView.reloadRows(at: [IndexPath(row: sectionIndex, section: 0)], with: .fade) } completion: { _ in viewStore.send(.sectionReloaded) } } } Stateを更新してもセルの高さがうま く変わらず、UITableViewを使ってセ ルをリロード

  21. 03
 実装方針の切り替え
 ©NewsPicks Inc. All Rights Reserved. 
 フルSwiftUI+TCAでは満足の行くアプリが作れないと判断し、 


    SwiftUI+UIKit+TCAの構成で作り直すことに

  22. ©NewsPicks Inc. All Rights Reserved. 
 04
 SwiftUI+UIKit+TCAの実装詳細


  23. 04
 SwiftUI+UIKit+TCAで作り直し
 ©NewsPicks Inc. All Rights Reserved. 
 フルSwiftUIでの実装は諦めたが、SwiftUI自体は諦めたくなかった。 


    そこで、フィードのガワの実装は慣れているUITableViewを使用し、セルをSwiftUIで実 装するという方針に切り替えた。 
 セルのみSwiftUIで実装するという方針に変更 
 UIKit
 SwiftUI

  24. 04
 Self Sizing Cell for SiwftUI
 ©NewsPicks Inc. All Rights

    Reserved. 
 final class HostingCell<Content: View>: UITableViewCell { private let hostingController = UIHostingController<Content?>(rootView: nil) override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) hostingController.view.backgroundColor = .clear } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // 次のスライドに続く https://noahgilmore.com/blog/swiftui-self-sizing-cells/
  25. 04
 Self Sizing Cell for SiwftUI
 ©NewsPicks Inc. All Rights

    Reserved. 
 final class HostingCell<Content: View>: UITableViewCell { private let hostingController = UIHostingController<Content?>(rootView: nil) override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) hostingController.view.backgroundColor = .clear } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // 次のスライドに続く https://noahgilmore.com/blog/swiftui-self-sizing-cells/ こちらのブログ記事のコードをほぼ そのまま拝借

  26. 04
 Self Sizing Cell for SiwftUI
 ©NewsPicks Inc. All Rights

    Reserved. 
 // HostingCellの実装の続き extension HostingCell { func set(rootView: Content, parentController: UIViewController) { self.hostingController.rootView = rootView self.hostingController.view.invalidateIntrinsicContentSize() let requiresControllerMove = hostingController.parent != parentController if requiresControllerMove { parentController.addChild(hostingController) } if !self.contentView.subviews.contains(hostingController.view) { self.contentView.addSubview(hostingController.view) hostingController.view.translatesAutoresizingMaskIntoConstraints = false hostingController.view.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor).isActive = true hostingController.view.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor).isActive = true hostingController.view.topAnchor.constraint(equalTo: self.contentView.topAnchor).isActive = true hostingController.view.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor).isActive = true } if requiresControllerMove { hostingController.didMove(toParent: parentController) } } }
  27. 04
 各セルのState
 ©NewsPicks Inc. All Rights Reserved. 
 enum FeedItemCore

    {} extension FeedItemCore { enum State: Equatable, Identifiable { case article(ArticleCore.State) case video(VideoCore.State) } var id: FeedItemId { switch self { case let .article(state): return state.id case let .video(state): return state.id } } }
  28. 04
 各セルのState
 ©NewsPicks Inc. All Rights Reserved. 
 enum FeedItemCore

    {} extension FeedItemCore { enum State: Equatable, Identifiable { case article(ArticleCore.State) case video(VideoCore.State) } var id: FeedItemId { switch self { case let .article(state): return state.id case let .video(state): return state.id } } } 記事セクション、動画セクション、 
 特集記事セクションなど20種類ほどある 

  29. 04
 ニュースフィードのState
 ©NewsPicks Inc. All Rights Reserved. 
 enum FeedCore

    {} extension FeedCore { struct State: Equatable { var items: IdentifiedArrayOf<FeedItemCore.State> = [] var updateItemsRequest: UpdateItemsRequest? var viewState: FeedCore.ViewState { .init(updateItemsRequest: updateItemsRequest) } } enum UpdateItemsRequest: Equatable { case refreshing(animating: Bool) case updating(params: [UpdateItemsRequestParam], animating: Bool) } enum UpdateItemsRequestParam: Equatable { case insert([FeedItemCore.State.ID]) case delete([FeedItemCore.State.ID]) case reload([FeedItemCore.State.ID]) } } extension FeedCore { struct ViewState: Equatable { let updateItemsRequest: UpdateItemsRequest? } }
  30. 04
 ニュースフィードのState
 ©NewsPicks Inc. All Rights Reserved. 
 enum FeedCore

    {} extension FeedCore { struct State: Equatable { var items: IdentifiedArrayOf<FeedItemCore.State> = [] var updateItemsRequest: UpdateItemsRequest? var viewState: FeedCore.ViewState { .init(updateItemsRequest: updateItemsRequest) } } enum UpdateItemsRequest: Equatable { case refreshing(animating: Bool) case updating(params: [UpdateItemsRequestParam], animating: Bool) } enum UpdateItemsRequestParam: Equatable { case insert([FeedItemCore.State.ID]) case delete([FeedItemCore.State.ID]) case reload([FeedItemCore.State.ID]) } } extension FeedCore { struct ViewState: Equatable { let updateItemsRequest: UpdateItemsRequest? } } セルのStateの配列 

  31. 04
 ニュースフィードのState
 ©NewsPicks Inc. All Rights Reserved. 
 enum FeedCore

    {} extension FeedCore { struct State: Equatable { var items: IdentifiedArrayOf<FeedItemCore.State> = [] var updateItemsRequest: UpdateItemsRequest? var viewState: FeedCore.ViewState { .init(updateItemsRequest: updateItemsRequest) } } enum UpdateItemsRequest: Equatable { case refreshing(animating: Bool) case updating(params: [UpdateItemsRequestParam], animating: Bool) } enum UpdateItemsRequestParam: Equatable { case insert([FeedItemCore.State.ID]) case delete([FeedItemCore.State.ID]) case reload([FeedItemCore.State.ID]) } } extension FeedCore { struct ViewState: Equatable { let updateItemsRequest: UpdateItemsRequest? } } データソースのSnapshotの更新指 示を表現
 リフレッシュやページング時に値を変 更してビューに通知 

  32. 04
 ニュースフィードのDataSource
 ©NewsPicks Inc. All Rights Reserved. 
 private var

    diffableDataSource: UITableViewDiffableDataSource<Section, FeedItemCore.State.ID>! diffableDataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, identifier -> UITableViewCell in guard let self = self else { return UITableViewCell() } guard let itemState = ViewStore(self.store).items[id: identifier] else { return UITableViewCell() } let itemStore: Store<FeedItemCore.State, FeedItemCore.Action> = self.store.scope( state: { $0.items[id: identifier] ?? itemState }, action: { .item(id: identifier, action: $0) } ) switch itemState { case .article: let store: Store<ArticleCore.State, ArticleCore.Action> = itemStore.scope( state: { parentState in (/FeedItemCore.State.article).extract(from: parentState)! }, action: FeedItemCore.Action.article) let cell = tableView.dequeueReusableCell( withIdentifier: String(describing: HostingCell<ArticleView>.self), for: indexPath) as! HostingCell<ArticleView> cell.setView(rootView: ArticleView(store: store), parentController: self) return cell case .video: ...
  33. 04
 ニュースフィードのDataSource
 ©NewsPicks Inc. All Rights Reserved. 
 private var

    diffableDataSource: UITableViewDiffableDataSource<Section, FeedItemCore.State.ID>! diffableDataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, identifier -> UITableViewCell in guard let self = self else { return UITableViewCell() } guard let itemState = ViewStore(self.store).items[id: identifier] else { return UITableViewCell() } let itemStore: Store<FeedItemCore.State, FeedItemCore.Action> = self.store.scope( state: { $0.items[id: identifier] ?? itemState }, action: { .item(id: identifier, action: $0) } ) switch itemState { case .article: let store: Store<ArticleCore.State, ArticleCore.Action> = itemStore.scope( state: { parentState in (/FeedItemCore.State.article).extract(from: parentState)! }, action: FeedItemCore.Action.article) let cell = tableView.dequeueReusableCell( withIdentifier: String(describing: HostingCell<ArticleView>.self), for: indexPath) as! HostingCell<ArticleView> cell.setView(rootView: ArticleView(store: store), parentController: self) return cell case .video: ... セルのStoreを生成

  34. 04
 ニュースフィードのDataSource
 ©NewsPicks Inc. All Rights Reserved. 
 private var

    diffableDataSource: UITableViewDiffableDataSource<Section, FeedItemCore.State.ID>! diffableDataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, identifier -> UITableViewCell in guard let self = self else { return UITableViewCell() } guard let itemState = ViewStore(self.store).items[id: identifier] else { return UITableViewCell() } let itemStore: Store<FeedItemCore.State, FeedItemCore.Action> = self.store.scope( state: { $0.items[id: identifier] ?? itemState }, action: { .item(id: identifier, action: $0) } ) switch itemState { case .article: let store: Store<ArticleCore.State, ArticleCore.Action> = itemStore.scope( state: { parentState in (/FeedItemCore.State.article).extract(from: parentState)! }, action: FeedItemCore.Action.article) let cell = tableView.dequeueReusableCell( withIdentifier: String(describing: HostingCell<ArticleView>.self), for: indexPath) as! HostingCell<ArticleView> cell.setView(rootView: ArticleView(store: store), parentController: self) return cell case .video: ... CasePathの仕組みで記事セルのStateを 抽出

  35. 04
 ニュースフィードのSnapshot更新
 ©NewsPicks Inc. All Rights Reserved. 
 viewStore.publisher.updateItemsRequest .sink

    { [weak self] updateRequest in guard let self = self, let updateRequest = updateRequest else { return } switch updateRequest { case let .refreshing(animating): var snapshot = NSDiffableDataSourceSnapshot<Section, AlpacaItemCore.State.ID>() snapshot.appendSections([.main]) snapshot.appendItems(ViewStore(self.store).items.map { $0.id }, toSection: .main) self.diffiableDataSource.apply(snapshot, animatingDifferences: animating) case let .updating(params, animating): var snapshot = self.diffiableDataSource.snapshot() for param in params { switch param { case let .insert(ids): snapshot.appendItems(ids) case let .delete(ids): snapshot.deleteItems(ids) case let .reload(ids): snapshot.reloadItems(ids) } } self.diffiableDataSource.apply(snapshot, animatingDifferences: animating) } self.viewStore.send(.updateItemsFinished) } .store(in: &cancellables)
  36. 04
 ニュースフィードのSnapshot更新
 ©NewsPicks Inc. All Rights Reserved. 
 viewStore.publisher.updateItemsRequest .sink

    { [weak self] updateRequest in guard let self = self, let updateRequest = updateRequest else { return } switch updateRequest { case let .refreshing(animating): var snapshot = NSDiffableDataSourceSnapshot<Section, AlpacaItemCore.State.ID>() snapshot.appendSections([.main]) snapshot.appendItems(ViewStore(self.store).items.map { $0.id }, toSection: .main) self.diffiableDataSource.apply(snapshot, animatingDifferences: animating) case let .updating(params, animating): var snapshot = self.diffiableDataSource.snapshot() for param in params { switch param { case let .insert(ids): snapshot.appendItems(ids) case let .delete(ids): snapshot.deleteItems(ids) case let .reload(ids): snapshot.reloadItems(ids) } } self.diffiableDataSource.apply(snapshot, animatingDifferences: animating) } self.viewStore.send(.updateItemsFinished) } .store(in: &cancellables) Snapshotの更新指示を 
 受け取る

  37. 04
 ニュースフィードのSnapshot更新
 ©NewsPicks Inc. All Rights Reserved. 
 viewStore.publisher.updateItemsRequest .sink

    { [weak self] updateRequest in guard let self = self, let updateRequest = updateRequest else { return } switch updateRequest { case let .refreshing(animating): var snapshot = NSDiffableDataSourceSnapshot<Section, AlpacaItemCore.State.ID>() snapshot.appendSections([.main]) snapshot.appendItems(ViewStore(self.store).items.map { $0.id }, toSection: .main) self.diffiableDataSource.apply(snapshot, animatingDifferences: animating) case let .updating(params, animating): var snapshot = self.diffiableDataSource.snapshot() for param in params { switch param { case let .insert(ids): snapshot.appendItems(ids) case let .delete(ids): snapshot.deleteItems(ids) case let .reload(ids): snapshot.reloadItems(ids) } } self.diffiableDataSource.apply(snapshot, animatingDifferences: animating) } self.viewStore.send(.updateItemsFinished) } .store(in: &cancellables) 更新指示に合わせてSnapshotを 更新する

  38. 04
 ニュースフィードのSnapshot更新
 ©NewsPicks Inc. All Rights Reserved. 
 viewStore.publisher.updateItemsRequest .sink

    { [weak self] updateRequest in guard let self = self, let updateRequest = updateRequest else { return } switch updateRequest { case let .refreshing(animating): var snapshot = NSDiffableDataSourceSnapshot<Section, AlpacaItemCore.State.ID>() snapshot.appendSections([.main]) snapshot.appendItems(ViewStore(self.store).items.map { $0.id }, toSection: .main) self.diffiableDataSource.apply(snapshot, animatingDifferences: animating) case let .updating(params, animating): var snapshot = self.diffiableDataSource.snapshot() for param in params { switch param { case let .insert(ids): snapshot.appendItems(ids) case let .delete(ids): snapshot.deleteItems(ids) case let .reload(ids): snapshot.reloadItems(ids) } } self.diffiableDataSource.apply(snapshot, animatingDifferences: animating) } self.viewStore.send(.updateItemsFinished) } .store(in: &cancellables) 更新完了アクションを送信する 

  39. 04
 SwiftUI+UIKit+TCA構成への移行はスムーズだった
 ©NewsPicks Inc. All Rights Reserved. 
 SwiftUIで実装していたセルのコンポーネン トはほぼそのまま移行できた

    
 ViewはHostingCellに そのまま載せ換えるだけ で移行でき、Core はロジックをあまり変更せずそのまま使用することができた 。
 フィードのCoreの実装は7割くらいをそのま ま流用できた
 フィードのビューはUIKitで作り直したが、Coreは感覚的には 実装 の7割くらいをそのまま流用 できた。
 TCAを導入したことで、 ビューの実装とビジネス・プレゼンテー ションロジックの実装を疎結合 にできていることがある程度証明 できた。

  40. 04
 スムーズなスクロールを実現する工夫
 ©NewsPicks Inc. All Rights Reserved. 
 スクロール時のアクション送信トリガーがたくさんあり、スクロール時に大量にアクションが送信されてしまっ ていた。


    これが原因でメインスレッドに負荷がかかり、スクロールがカクつくようになっていた。 
 • セルの表示判定
 • セル内の動画の自動再生・停止判定 
 • セルおよびセル内の各コンポーネントのonAppear 
 スクロールカクつきの原因は大量のアクション送信 

  41. 04
 スムーズなスクロールを実現する工夫
 ©NewsPicks Inc. All Rights Reserved. 
 記事が画面に表示されたかどうかを判定し、ログを送信するロジックがあ る。


    スクロール位置が変わるたびに判定処理が実行されるため、大量にアク ションが送信されていた。 
 スクロールするたびにアクションが 
 送信されていた
 func scrollViewDidScroll(_ scrollView: UIScrollView) { guard let tableView = scrollView as? UITableView else { return } let indexes = tableView.viewableCells() .compactMap { tableView.indexPath(for: $0)?.row } if !indexes.isEmpty { viewStore.send(.onItemsViewable(indexes)) } } 画面に表示されている セルの一覧を取得
 アクションを送信する 

  42. 04
 スムーズなスクロールを実現する工夫
 ©NewsPicks Inc. All Rights Reserved. 
 アクション送信の数を抑制するため、すでにログを送信済みのセルはフィ ルタするロジックをビューに持たせた。

    
 Reducerにはフィルタのロジックがあったが、アクション送信の数を減らすに はビュー側でフィルタする必要がある。 
 ビューにロジックを持たせるのは極力避けるべきだが、Stateのフラグを参 照するだけにするなど最低限のロジックになるように工夫はしている。 
 ビュー側にアクション送信を抑制するロジックを持 たせる
 func scrollViewDidScroll(_ scrollView: UIScrollView) { guard let tableView = scrollView as? UITableView else { return } let indexes = tableView.viewableCells() .compactMap { tableView.indexPath(for: $0)?.row } .filter { let itemState = ViewStore(store).items[$0] return itemState.shouldSendViewableLog && !ViewStore(store).viewableItemIds.contains(itemState.id) } if !indexes.isEmpty { viewStore.send(.onItemsViewable(indexes)) } } すでにログを送信済みかど うかをチェック

  43. 04
 スムーズなスクロールを実現する工夫
 ©NewsPicks Inc. All Rights Reserved. 
 記事セルは複数のコンポーネントで構成されており、各コンポーネントでonAppearアク ションを送信して表示時の処理を行っていた。

    
 セルが表示されるときに複数のonAppearアクションが同時送信されることになるので、 これもスクロールのパフォーマンスを悪化させる原因となっていた。 
 複数コンポーネントでonAppearを同時送信してい た
 ArticleViewで onAppearを送信
 ArticleTitleViewでも onAppearを送信

  44. 04
 スムーズなスクロールを実現する工夫
 ©NewsPicks Inc. All Rights Reserved. 
 各コンポーネントのonAppearで処理をするのではなく、フィードのReducerで処理をさせ るなどしてアクション送信数を減らした。

    
 onAppearで処理するのを止めた 
 onAppearを送信しな い
 onAppearを送信しない 

  45. 04
 SwiftUI+UIKit+TCA構成で感じているメリット
 ©NewsPicks Inc. All Rights Reserved. 
 UIKitのノウハウが
 活かせる


    これまで培ってきたUIKitのノウハウが活 かせ、細かな要件も実装することができ る。
 SwiftUIの機能不足、不安定さをカバー するワークアラウンドも無いので、 コード の読みやすさ、テスタビリティを高く維持 することができる。
 XIBとサヨナラできた 
 これまでセルはXIBでレイアウトしてきた が、XIBのメンテはコストがかかるしレ ビューも難しいので生産性を下げる一因 になっていた。
 セルをSwiftUIで実装できるようになった ことでXIBを排除でき、さらにビュー実装 の生産性は格段に上がる 。
 TCAのメリットはしっかりと 享受できている
 コードの読みやすさ、テスタビリティの高 さといったTCAのメリットはしっかりと享 受できている。
 UIKitのサポートは今のところifLetと AlertStateくらいしかないが、 特に困るこ ともない。

  46. 04
 TCAを導入したアーキテクチャに対しての現時点での評価
 ©NewsPicks Inc. All Rights Reserved. 
 TCA自体の導入効果は大きい 


    コードの可読性、テスタビリティは明らかに向上しており、 理想の アーキテクチャに近づくことができている と言える。
 新規メンバーによるコードのキャッチアップも以前のアーキテク チャより断然速く、生産性が向上したという結果も出ている。 
 SwiftUIと組み合わせたときのパワーが発揮 しきれていない
 問題が起きたときに SwiftUIとTCAどちらの問題なのか切り分け るのが難しいこと、SwiftUI自体の機能不足や動作の不安定さ、 開発メンバーのSwiftUIの習熟度がまだ十分でないことなどか ら、TCAの本来のパワーを我々がまだ活かしきれていない と感じ ている。
 今後も実装経験を重ね、SwiftUI+TCAのパワーを発揮できる状 態に持っていきたい。 

  47. 1. NewsPicks iOSアプリのリアーキテクチャの背景
 2. リアーキテクチャで目指しているものと技術選定
 3. メイン画面をSwiftUI+TCAで実装、しかし...
 4. SwiftUI+UIKit+TCAの実装詳細
 00


    今日話したこと
 ©NewsPicks Inc. All Rights Reserved. 

  48. ©NewsPicks Inc. All Rights Reserved. 
 05
 Appendix


  49. ©NewsPicks Inc. All Rights Reserved. 
 05
 まずは小さく。TCAの始め方


  50. 05
 最初のTCA採用機能の開発
 ©NewsPicks Inc. All Rights Reserved. 
 既存コードへの依存が少なくシンプルな仕様の新 機能の実装でTCAを取り入れた

    
 記事をカテゴリごとに分類してアクセスしやすくする法人向け機能。 
 デザインも機能もシンプルでTCAを試すには丁度良い機能だった。 

  51. 05
 画面構成
 ©NewsPicks Inc. All Rights Reserved. 
 ArchiveView (SwiftUI)

    RootViewController (UIKit) EnterpriseFeedViewController (UIKit)
  52. 05
 State
 ©NewsPicks Inc. All Rights Reserved. 
 アプリの全ての状態をStateで表現するのが理想だが、プロジェクトに途中から導入して いるのでそれは不可能。

    
 まず、ルートのStateとしてAppCore.Stateを定義。あとは、画面構成に合わせて下位の Stateを定義した。
 enum AppCore {} extension AppCore { struct State: Equatable { var root = RootCore.State() } } enum RootCore {} extension RootCore { struct State: Equatable { var enterpriseFeed = EnterpriseFeedCore.State() } } enum EnterpriseFeedCore {} extension EnterpriseFeedCore { struct State: Equatable { var archive = Archive.State() } } enum ArchiveCore {} extension ArchiveCore { struct State: Equatable {} } まずは最小限のStateだけを定義 

  53. 05
 Environment
 ©NewsPicks Inc. All Rights Reserved. 
 各CoreのEnvironmentではRepositoryプロトコルを依存として宣言しており、ユ ニットテスト時はモックで置き換えられるようにしている。

    
 ルートであるAppCore.Environmentのエクステンションで、Repositoryの実装であ るRepositoryImplのインスタンスを作成している。 
 extension AppCore.Environment { static let live: AppCore.Environment = Self( scheduler: DispatchQueue.main.eraseToAnyScheduler(), archiveRepository: ArchiveRepositoryImpl(apiClient: .live) ) } extension RootCore { struct Environment { let scheduler: AnySchedulerOf<DispatchQueue> let archiveRepository: ArchiveRepository } } Environmentはモックで置き換え可能に 
 実装
 プロトコル

  54. 05
 Storeの受け渡し
 ©NewsPicks Inc. All Rights Reserved. 
 @UIApplicationMain class

    AppDelegate: UIResponder, UIApplicationDelegate { let store = Store( initialState: AppCore.State(), reducer: AppCore.reducer, environment: .live) ... let vc = RootViewController(store: store.scope(state: \.root, action: AppCore.Action.root)) window?.rootViewController = vc window?.makeKeyAndVisible() } class RootViewController: UIViewController { private let store: Store<RootCore.State, RootCore.Action> init(store: Store<RootCore.State, RootCore.Action>) { self.store = store super.init(nibName: nil, bundle: nil) } }
  55. 05
 Environment
 ©NewsPicks Inc. All Rights Reserved. 
 ArchiveViewからArchiveDetailViewへの画面遷移は EnterpriseFeedViewControllerで行うようにした。

    
 UIKitサポートの機能であるifLetを使用することができる。 
 画面遷移はUIKitで
 class EnterpriseFeedViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() store.scope(state: \.archiveDetail, action: EnterpriseFeedCore.Action.archiveDetail) .ifLet { [weak self] scopedStore in let vc = UIHostingController(rootView: ArchiveDetailView(store: scopedStore)) self?.navigationController?.pushViewController(vc, animated: true) } .store(in: &cancellables) } }
  56. 05
 最初のTCA実装を終えて
 ©NewsPicks Inc. All Rights Reserved. 
 機能がシンプル、かつ他の画面とのインタラクションが無いというのもあり、特にハマるこ となく実装できた。


    TCAは問題なかったが、SwiftUIの機能不足を痛感(ScrollViewにRefresh機能がないと か)。
 余談だが、この機能を実装している最中にSwitchStoreがリリースされたのはタイムリー だった。
 ※自分が出したDiscussionsへの投稿 https://github.com/pointfreeco/swift-composable-architecture/discussions/590 
 大きな課題なく実装できた