Slide 1

Slide 1 text

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


Slide 2

Slide 2 text

00
 自己紹介
 ©NewsPicks Inc. All Rights Reserved. 
 金子 雄大
 NewsPicks iOSエンジニア 
 @takehilo_kaneko
 takehilo
 takehilo


Slide 3

Slide 3 text

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


Slide 4

Slide 4 text

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


Slide 5

Slide 5 text

01
 ビジネス優先で走り続けてきた
 ©NewsPicks Inc. All Rights Reserved. 
 NewsPicksはリリースから8年以上が経過。 
 その間、何度もUIをリニューアル してきた。
 一方で、コードの保守性は置き去り にされたまま。
 iOSアプリのコードは限界に... 


Slide 6

Slide 6 text

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


Slide 7

Slide 7 text

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


Slide 8

Slide 8 text

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


Slide 9

Slide 9 text

02
 目指している姿
 ©NewsPicks Inc. All Rights Reserved. 
 ● 各事業チームにiOSエンジニアを配置し、事業単位で並行 で機能開発できる体制を作りたい 
 ● iOSエンジニアを増やせば生産性が上がる状態を作りたい 
 
 この体制に耐えうるアーキテクチャを作ることが現在のミッション 
 事業ごとに並行開発可能なアーキテクチャ 
 課金事業 広告事業 法人事業 新規事業 プロダクト チーム iOSチーム

Slide 10

Slide 10 text

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


Slide 11

Slide 11 text

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


Slide 12

Slide 12 text

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


Slide 13

Slide 13 text

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


Slide 14

Slide 14 text

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


Slide 15

Slide 15 text

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


Slide 16

Slide 16 text

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


Slide 17

Slide 17 text

03
 スクロールがカクつくようになってしまった
 ©NewsPicks Inc. All Rights Reserved. 
 SwiftUIのList、TCAのForEachStore、SwitchStoreなどを使ってニュースフィードを実装し たが、スクロールがひどくカクつくようになってしまった。 
 ソースのイメージ:
 https://github.com/pointfreeco/swift-composable-architecture/discussions/1027 
 ビューのレイアウトの仕方や、無駄な再描画が起きていることなどが原因とみて色々調査 していたものの、スムーズなスクロールを実現することができなかった。 
 原因がよくわからなかった 


Slide 18

Slide 18 text

03
 スクロールがカクつくようになってしまった
 ©NewsPicks Inc. All Rights Reserved. 
 SwiftUIのList、TCAのForEachStore、SwitchStoreなどを使ってニュースフィードを実装し たが、スクロールがひどくカクつくようになってしまった。 
 ソースのイメージ:
 https://github.com/pointfreeco/swift-composable-architecture/discussions/1027 
 ビューのレイアウトの仕方や、無駄な再描画が起きていることなどが原因とみて色々調査 していたものの、スムーズなスクロールを実現することができなかった。 
 原因がよくわからなかった 
 後日検証した結果、原因が判明。 
 後のスライドで紹介します。 


Slide 19

Slide 19 text

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


Slide 20

Slide 20 text

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を使ってセ ルをリロード


Slide 21

Slide 21 text

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


Slide 22

Slide 22 text

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


Slide 23

Slide 23 text

04
 SwiftUI+UIKit+TCAで作り直し
 ©NewsPicks Inc. All Rights Reserved. 
 フルSwiftUIでの実装は諦めたが、SwiftUI自体は諦めたくなかった。 
 そこで、フィードのガワの実装は慣れているUITableViewを使用し、セルをSwiftUIで実 装するという方針に切り替えた。 
 セルのみSwiftUIで実装するという方針に変更 
 UIKit
 SwiftUI


Slide 24

Slide 24 text

04
 Self Sizing Cell for SiwftUI
 ©NewsPicks Inc. All Rights Reserved. 
 final class HostingCell: UITableViewCell { private let hostingController = UIHostingController(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/

Slide 25

Slide 25 text

04
 Self Sizing Cell for SiwftUI
 ©NewsPicks Inc. All Rights Reserved. 
 final class HostingCell: UITableViewCell { private let hostingController = UIHostingController(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/ こちらのブログ記事のコードをほぼ そのまま拝借


Slide 26

Slide 26 text

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) } } }

Slide 27

Slide 27 text

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 } } }

Slide 28

Slide 28 text

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種類ほどある 


Slide 29

Slide 29 text

04
 ニュースフィードのState
 ©NewsPicks Inc. All Rights Reserved. 
 enum FeedCore {} extension FeedCore { struct State: Equatable { var items: IdentifiedArrayOf = [] 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? } }

Slide 30

Slide 30 text

04
 ニュースフィードのState
 ©NewsPicks Inc. All Rights Reserved. 
 enum FeedCore {} extension FeedCore { struct State: Equatable { var items: IdentifiedArrayOf = [] 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の配列 


Slide 31

Slide 31 text

04
 ニュースフィードのState
 ©NewsPicks Inc. All Rights Reserved. 
 enum FeedCore {} extension FeedCore { struct State: Equatable { var items: IdentifiedArrayOf = [] 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の更新指 示を表現
 リフレッシュやページング時に値を変 更してビューに通知 


Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

04
 ニュースフィードのDataSource
 ©NewsPicks Inc. All Rights Reserved. 
 private var diffableDataSource: UITableViewDiffableDataSource! 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 = self.store.scope( state: { $0.items[id: identifier] ?? itemState }, action: { .item(id: identifier, action: $0) } ) switch itemState { case .article: let store: Store = itemStore.scope( state: { parentState in (/FeedItemCore.State.article).extract(from: parentState)! }, action: FeedItemCore.Action.article) let cell = tableView.dequeueReusableCell( withIdentifier: String(describing: HostingCell.self), for: indexPath) as! HostingCell cell.setView(rootView: ArticleView(store: store), parentController: self) return cell case .video: ... セルのStoreを生成


Slide 34

Slide 34 text

04
 ニュースフィードのDataSource
 ©NewsPicks Inc. All Rights Reserved. 
 private var diffableDataSource: UITableViewDiffableDataSource! 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 = self.store.scope( state: { $0.items[id: identifier] ?? itemState }, action: { .item(id: identifier, action: $0) } ) switch itemState { case .article: let store: Store = itemStore.scope( state: { parentState in (/FeedItemCore.State.article).extract(from: parentState)! }, action: FeedItemCore.Action.article) let cell = tableView.dequeueReusableCell( withIdentifier: String(describing: HostingCell.self), for: indexPath) as! HostingCell cell.setView(rootView: ArticleView(store: store), parentController: self) return cell case .video: ... CasePathの仕組みで記事セルのStateを 抽出


Slide 35

Slide 35 text

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() 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)

Slide 36

Slide 36 text

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() 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の更新指示を 
 受け取る


Slide 37

Slide 37 text

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() 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を 更新する


Slide 38

Slide 38 text

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() 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) 更新完了アクションを送信する 


Slide 39

Slide 39 text

04
 SwiftUI+UIKit+TCA構成への移行はスムーズだった
 ©NewsPicks Inc. All Rights Reserved. 
 SwiftUIで実装していたセルのコンポーネン トはほぼそのまま移行できた 
 ViewはHostingCellに そのまま載せ換えるだけ で移行でき、Core はロジックをあまり変更せずそのまま使用することができた 。
 フィードのCoreの実装は7割くらいをそのま ま流用できた
 フィードのビューはUIKitで作り直したが、Coreは感覚的には 実装 の7割くらいをそのまま流用 できた。
 TCAを導入したことで、 ビューの実装とビジネス・プレゼンテー ションロジックの実装を疎結合 にできていることがある程度証明 できた。


Slide 40

Slide 40 text

04
 スムーズなスクロールを実現する工夫
 ©NewsPicks Inc. All Rights Reserved. 
 スクロール時のアクション送信トリガーがたくさんあり、スクロール時に大量にアクションが送信されてしまっ ていた。
 これが原因でメインスレッドに負荷がかかり、スクロールがカクつくようになっていた。 
 ● セルの表示判定
 ● セル内の動画の自動再生・停止判定 
 ● セルおよびセル内の各コンポーネントのonAppear 
 スクロールカクつきの原因は大量のアクション送信 


Slide 41

Slide 41 text

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)) } } 画面に表示されている セルの一覧を取得
 アクションを送信する 


Slide 42

Slide 42 text

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)) } } すでにログを送信済みかど うかをチェック


Slide 43

Slide 43 text

04
 スムーズなスクロールを実現する工夫
 ©NewsPicks Inc. All Rights Reserved. 
 記事セルは複数のコンポーネントで構成されており、各コンポーネントでonAppearアク ションを送信して表示時の処理を行っていた。 
 セルが表示されるときに複数のonAppearアクションが同時送信されることになるので、 これもスクロールのパフォーマンスを悪化させる原因となっていた。 
 複数コンポーネントでonAppearを同時送信してい た
 ArticleViewで onAppearを送信
 ArticleTitleViewでも onAppearを送信


Slide 44

Slide 44 text

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


Slide 45

Slide 45 text

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


Slide 46

Slide 46 text

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


Slide 47

Slide 47 text

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


Slide 48

Slide 48 text

©NewsPicks Inc. All Rights Reserved. 
 05
 Appendix


Slide 49

Slide 49 text

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


Slide 50

Slide 50 text

05
 最初のTCA採用機能の開発
 ©NewsPicks Inc. All Rights Reserved. 
 既存コードへの依存が少なくシンプルな仕様の新 機能の実装でTCAを取り入れた 
 記事をカテゴリごとに分類してアクセスしやすくする法人向け機能。 
 デザインも機能もシンプルでTCAを試すには丁度良い機能だった。 


Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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だけを定義 


Slide 53

Slide 53 text

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 let archiveRepository: ArchiveRepository } } Environmentはモックで置き換え可能に 
 実装
 プロトコル


Slide 54

Slide 54 text

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 init(store: Store) { self.store = store super.init(nibName: nil, bundle: nil) } }

Slide 55

Slide 55 text

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) } }

Slide 56

Slide 56 text

05
 最初のTCA実装を終えて
 ©NewsPicks Inc. All Rights Reserved. 
 機能がシンプル、かつ他の画面とのインタラクションが無いというのもあり、特にハマるこ となく実装できた。
 TCAは問題なかったが、SwiftUIの機能不足を痛感(ScrollViewにRefresh機能がないと か)。
 余談だが、この機能を実装している最中にSwitchStoreがリリースされたのはタイムリー だった。
 ※自分が出したDiscussionsへの投稿 https://github.com/pointfreeco/swift-composable-architecture/discussions/590 
 大きな課題なく実装できた