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

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エンジニア / 金子 雄大


    View full-size slide

  2. 00
 自己紹介

    ©NewsPicks Inc. All Rights Reserved. 

    金子 雄大

    NewsPicks iOSエンジニア 

    @takehilo_kaneko

    takehilo

    takehilo


    View full-size slide

  3. 1. NewsPicks iOSアプリのリアーキテクチャの背景

    2. リアーキテクチャで目指しているものと技術選定

    3. メイン画面をSwiftUI+TCAで実装、しかし...

    4. SwiftUI+UIKit+TCAの実装詳細

    00
 今日話すこと

    ©NewsPicks Inc. All Rights Reserved. 


    View full-size slide

  4. ©NewsPicks Inc. All Rights Reserved. 

    01

    NewsPicks iOSアプリの

    リアーキテクチャの背景


    View full-size slide

  5. 01
 ビジネス優先で走り続けてきた

    ©NewsPicks Inc. All Rights Reserved. 

    NewsPicksはリリースから8年以上が経過。

    その間、何度もUIをリニューアル
    してきた。

    一方で、コードの保守性は置き去り
    にされたまま。

    iOSアプリのコードは限界に... 


    View full-size slide

  6. NewsPicksはこれから益々ビジネスを拡大していこうとしている

    このままではビジネスのスピードにコードがついていけなくなる

    そこで2021年7月より、リアーキテクチャを開始


    View full-size slide

  7. ©NewsPicks Inc. All Rights Reserved. 

    02

    リアーキテクチャで

    目指しているものと技術選定


    View full-size slide

  8. 02
 ビジネスの拡大に伴って出てきた課題

    ©NewsPicks Inc. All Rights Reserved. 

    バグが発生しやすい 

    なかなかのスパゲッティコードになってい
    るので、コードを深く理解している人でな
    いと考慮不足でバグを発生させてしまい
    やすい状態になっていた。

    人が増えても生産性が 

    上がらない

    実装に明確なルールがなかったので
    コードの可読性、テスタビリティが低く、
    途中から入ったメンバーがコードを把握
    するのが難しい状態になっていた。

    新規メンバーは現状のコードを理解する
    ところから始める必要があり、
    それに時
    間がかかるので、人が増えても生産性
    がなかなか上がらないという状態だっ
    た。

    ビジネスのスピードに 

    追いつかない

    ビジネスの拡大と共に開発要望も爆増し
    ているが、保守性の低さが原因で
    開発
    のスピードがビジネス要求に応えられな
    くなってきた。


    View full-size slide

  9. 02
 目指している姿

    ©NewsPicks Inc. All Rights Reserved. 

    ● 各事業チームにiOSエンジニアを配置し、事業単位で並行
    で機能開発できる体制を作りたい

    ● iOSエンジニアを増やせば生産性が上がる状態を作りたい


    この体制に耐えうるアーキテクチャを作ることが現在のミッション

    事業ごとに並行開発可能なアーキテクチャ 

    課金事業
    広告事業
    法人事業
    新規事業
    プロダクト
    チーム
    iOSチーム

    View full-size slide

  10. 02
 理想のアーキテクチャにするために採り入れていること

    ©NewsPicks Inc. All Rights Reserved. 

    レイヤー分割、

    マルチモジュール化 

    Domain、Infrastructure、Presentationと
    いったレイヤーを定義し、ビジネス・プレ
    ゼンテーションロジックをテストしやすい
    構造に変更。

    さらに、それぞれをフレームワーク化して
    依存関係を強制することで、正しい構造
    を長期に渡って保てるようにする。

    実装ルールをある程度 

    強制する

    実装ルールを決めるだけでは秩序は保
    てない。人数が増えれば増えるほど、秩
    序を保つのは難しくなる。

    ツールやライブラリを活用し、実装ルー
    ルをある程度強制することで、誰が実装
    しても読みやすく保守しやすいコードに
    なるようにする

    UI実装の効率化

    NewsPicksでは頻繁にABテストを行って
    おり、UI変更の頻度が非常に多い。

    UI実装を効率化し、フィードバックサイク
    ルを速く回せるようにする。

    View full-size slide

  11. 02
 理想のアーキテクチャにするために採り入れていること


    ©NewsPicks Inc. All Rights Reserved. 

    レイヤー分割、

    マルチモジュール化 

    Domain、Infrastructure、Presentationと
    いったレイヤーを定義し、ビジネス・プレ
    ゼンテーションロジックをテストしやすい
    構造に変更。

    さらに、それぞれをフレームワーク化して
    依存関係を強制することで、正しい構造
    を長期に渡って保てるようにする。

    実装ルールをある程度 

    強制する

    実装ルールを決めるだけでは秩序は保
    てない。人数が増えれば増えるほど、秩
    序を保つのは難しくなる。

    ツールやライブラリを活用し、実装ルー
    ルをある程度強制することで、誰が実装
    しても読みやすく保守しやすいコードに
    なるようにする

    UI実装の効率化

    NewsPicksでは頻繁にABテストを行って
    おり、UI変更の頻度が非常に多い。

    UI実装を効率化し、フィードバックサイク
    ルを速く回せるようにする。

    ・レイヤードアーキテクチャ

    ・XcodeGen

    ・TCA

    ・SwiftLint

    ・SwiftFormat

    ・SwiftUI


    View full-size slide

  12. 02
 なぜThe Composable Architectureを採用したのか?

    ©NewsPicks Inc. All Rights Reserved. 

    以前からReduxの採用を考えていた 

    iOSアプリでは複雑で多様な状態変化を管理する必要がある。

    NewsPicksアプリのニュースフィードでは様々な種類のセルが登場し、さらに各セルでは
    様々なイベント、状態変化が起きうるので、複雑なコードになりがち。

    さらにNewsPicksではデータドリブンな開発を行っていて、アプリから
    多種多様なログを送信
    している。ルートのコンポーネントが複数の下位コンポーネントの状態を把握した上でログ
    を送る、などの要件もあり複雑さの原因となっている。

    個人的にReSwiftを使ってReduxを導入した経験があり、
    Reduxがこうした複雑さを一定解消
    してくれることは知っていたので、Reduxを導入したいと思っていた。

    View full-size slide

  13. 02
 なぜThe Composable Architectureを採用したのか?

    ©NewsPicks Inc. All Rights Reserved. 

    Reduxライク

    Reduxが分かっていれば理解しやすいアーキテクチャだったので、
    個人的な学習コストが低かった

    UIKitのサポートがある 

    現状そこまで充実しているわけではないが、UIKitでも使える。
    UIKitベースのプロジェクトに導入するにあたってこれは嬉しい。

    DIの仕組みが用意されている 

    テスタブルな構造にするにあたってDIをどう実装するかは課題とな
    るが、その仕組みがライブラリに備わっている

    ユニットテストのサポートがある 

    ユニットテストを書くためのサポートがしっかりと用意されている。
    TCAのREADMEでもテスタビリティを強くアピールしているように見
    受けられるので安心。

    話題のTCAでサンプル実装を行い、以下の観点で良いと感じて採用。 


    View full-size slide

  14. ©NewsPicks Inc. All Rights Reserved. 

    03

    メイン画面をSwiftUI+TCAで実装

    しかし...


    View full-size slide

  15. 03
 メイン画面のリニューアル

    ©NewsPicks Inc. All Rights Reserved. 

    SwiftUI+TCAで作り直す 

    ビジネス的な課題意識から、メイン画面をリニューアルするプロジェクトが始まった。

    メイン画面は特に細かな仕様が多く、
    既存コードの複雑性はアプリ内随一
    。一番作り直したいと思って
    いた画面なので、この機会にSwiftUI+TCAで実装するというチャレンジをすることにした。

    View full-size slide

  16. 03
 SwiftUI+TCAの実装で出てきた課題

    ©NewsPicks Inc. All Rights Reserved. 

    スクロールがカクつくように 

    なってしまった

    SwiftUIのListを使ってニュースフィードを実装したが、スクロール
    がひどくカクつくようになってしまった。

    スペックが低い端末だとカクつきは顕著になり、とてもユーザ体
    験が良いとは言えない状態に。

    UIKitでの作り直し、ワークアラウンドの増加
    で開発コストが増加

    最初SwiftUIで実装していたところがあと一歩のところで要件を満
    たせないなどが判明し、UIKitをラップして作り直すということが何
    度か発生した。

    また、SwiftUI-Introspectへの依存が増えていったりして今後の
    メンテが不安になっていった

    View full-size slide

  17. 03
 スクロールがカクつくようになってしまった

    ©NewsPicks Inc. All Rights Reserved. 

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

    ソースのイメージ:

    https://github.com/pointfreeco/swift-composable-architecture/discussions/1027

    ビューのレイアウトの仕方や、無駄な再描画が起きていることなどが原因とみて色々調査
    していたものの、スムーズなスクロールを実現することができなかった。

    原因がよくわからなかった 


    View full-size slide

  18. 03
 スクロールがカクつくようになってしまった

    ©NewsPicks Inc. All Rights Reserved. 

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

    ソースのイメージ:

    https://github.com/pointfreeco/swift-composable-architecture/discussions/1027

    ビューのレイアウトの仕方や、無駄な再描画が起きていることなどが原因とみて色々調査
    していたものの、スムーズなスクロールを実現することができなかった。

    原因がよくわからなかった 

    後日検証した結果、原因が判明。 

    後のスライドで紹介します。 


    View full-size slide

  19. 03
 UIKitでの作り直しの例

    ©NewsPicks Inc. All Rights Reserved. 

    SwiftUIだと思うような画面を作れなかった 

    カルーセルをSwiftUIで実装(LazyHStack、GeometryReader、DragGestureなど)していたが、アニメー
    ションが不自然だったり、スクロールのパフォーマンスが悪くなるなどしたため、UICollectionViewで実装
    し直した。

    その他にも、上タブを実現するSwiftUIライブラリを入れてみたがタブ切り替えが重くなるなどしてうまくい
    かず、UIKitベースの実装で作り直した。

    View full-size slide

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


    View full-size slide

  21. 03
 実装方針の切り替え

    ©NewsPicks Inc. All Rights Reserved. 

    フルSwiftUI+TCAでは満足の行くアプリが作れないと判断し、

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


    View full-size slide

  22. ©NewsPicks Inc. All Rights Reserved. 

    04

    SwiftUI+UIKit+TCAの実装詳細


    View full-size slide

  23. 04
 SwiftUI+UIKit+TCAで作り直し

    ©NewsPicks Inc. All Rights Reserved. 

    フルSwiftUIでの実装は諦めたが、SwiftUI自体は諦めたくなかった。

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

    セルのみSwiftUIで実装するという方針に変更 

    UIKit

    SwiftUI


    View full-size slide

  24. 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/

    View full-size slide

  25. 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/
    こちらのブログ記事のコードをほぼ
    そのまま拝借


    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  29. 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?
    }
    }

    View full-size slide

  30. 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の配列

    View full-size slide

  31. 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の更新指
    示を表現

    リフレッシュやページング時に値を変
    更してビューに通知

    View full-size slide

  32. 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:
    ...

    View full-size slide

  33. 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を生成


    View full-size slide

  34. 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を
    抽出


    View full-size slide

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

    View full-size slide

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

    受け取る


    View full-size slide

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


    View full-size slide

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

    View full-size slide

  39. 04
 SwiftUI+UIKit+TCA構成への移行はスムーズだった

    ©NewsPicks Inc. All Rights Reserved. 

    SwiftUIで実装していたセルのコンポーネン
    トはほぼそのまま移行できた 

    ViewはHostingCellに
    そのまま載せ換えるだけ
    で移行でき、Core
    はロジックをあまり変更せずそのまま使用することができた
    。

    フィードのCoreの実装は7割くらいをそのま
    ま流用できた

    フィードのビューはUIKitで作り直したが、Coreは感覚的には
    実装
    の7割くらいをそのまま流用
    できた。

    TCAを導入したことで、
    ビューの実装とビジネス・プレゼンテー
    ションロジックの実装を疎結合
    にできていることがある程度証明
    できた。


    View full-size slide

  40. 04
 スムーズなスクロールを実現する工夫

    ©NewsPicks Inc. All Rights Reserved. 

    スクロール時のアクション送信トリガーがたくさんあり、スクロール時に大量にアクションが送信されてしまっ
    ていた。

    これが原因でメインスレッドに負荷がかかり、スクロールがカクつくようになっていた。

    ● セルの表示判定

    ● セル内の動画の自動再生・停止判定

    ● セルおよびセル内の各コンポーネントのonAppear

    スクロールカクつきの原因は大量のアクション送信 


    View full-size slide

  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))
    }
    }
    画面に表示されている
    セルの一覧を取得

    アクションを送信する

    View full-size slide

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


    View full-size slide

  43. 04
 スムーズなスクロールを実現する工夫

    ©NewsPicks Inc. All Rights Reserved. 

    記事セルは複数のコンポーネントで構成されており、各コンポーネントでonAppearアク
    ションを送信して表示時の処理を行っていた。

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

    複数コンポーネントでonAppearを同時送信してい
    た

    ArticleViewで
    onAppearを送信

    ArticleTitleViewでも
    onAppearを送信


    View full-size slide

  44. 04
 スムーズなスクロールを実現する工夫

    ©NewsPicks Inc. All Rights Reserved. 

    各コンポーネントのonAppearで処理をするのではなく、フィードのReducerで処理をさせ
    るなどしてアクション送信数を減らした。

    onAppearで処理するのを止めた 

    onAppearを送信しな
    い

    onAppearを送信しない

    View full-size slide

  45. 04
 SwiftUI+UIKit+TCA構成で感じているメリット

    ©NewsPicks Inc. All Rights Reserved. 

    UIKitのノウハウが

    活かせる

    これまで培ってきたUIKitのノウハウが活
    かせ、細かな要件も実装することができ
    る。

    SwiftUIの機能不足、不安定さをカバー
    するワークアラウンドも無いので、
    コード
    の読みやすさ、テスタビリティを高く維持
    することができる。

    XIBとサヨナラできた 

    これまでセルはXIBでレイアウトしてきた
    が、XIBのメンテはコストがかかるしレ
    ビューも難しいので生産性を下げる一因
    になっていた。

    セルをSwiftUIで実装できるようになった
    ことでXIBを排除でき、さらにビュー実装
    の生産性は格段に上がる
    。

    TCAのメリットはしっかりと
    享受できている

    コードの読みやすさ、テスタビリティの高
    さといったTCAのメリットはしっかりと享
    受できている。

    UIKitのサポートは今のところifLetと
    AlertStateくらいしかないが、
    特に困るこ
    ともない。


    View full-size slide

  46. 04
 TCAを導入したアーキテクチャに対しての現時点での評価

    ©NewsPicks Inc. All Rights Reserved. 

    TCA自体の導入効果は大きい 

    コードの可読性、テスタビリティは明らかに向上しており、
    理想の
    アーキテクチャに近づくことができている
    と言える。

    新規メンバーによるコードのキャッチアップも以前のアーキテク
    チャより断然速く、生産性が向上したという結果も出ている。

    SwiftUIと組み合わせたときのパワーが発揮
    しきれていない

    問題が起きたときに
    SwiftUIとTCAどちらの問題なのか切り分け
    るのが難しいこと、SwiftUI自体の機能不足や動作の不安定さ、
    開発メンバーのSwiftUIの習熟度がまだ十分でないことなどか
    ら、TCAの本来のパワーを我々がまだ活かしきれていない
    と感じ
    ている。

    今後も実装経験を重ね、SwiftUI+TCAのパワーを発揮できる状
    態に持っていきたい。

    View full-size slide

  47. 1. NewsPicks iOSアプリのリアーキテクチャの背景

    2. リアーキテクチャで目指しているものと技術選定

    3. メイン画面をSwiftUI+TCAで実装、しかし...

    4. SwiftUI+UIKit+TCAの実装詳細

    00
 今日話したこと

    ©NewsPicks Inc. All Rights Reserved. 


    View full-size slide

  48. ©NewsPicks Inc. All Rights Reserved. 

    05

    Appendix


    View full-size slide

  49. ©NewsPicks Inc. All Rights Reserved. 

    05

    まずは小さく。TCAの始め方


    View full-size slide

  50. 05
 最初のTCA採用機能の開発

    ©NewsPicks Inc. All Rights Reserved. 

    既存コードへの依存が少なくシンプルな仕様の新
    機能の実装でTCAを取り入れた 

    記事をカテゴリごとに分類してアクセスしやすくする法人向け機能。

    デザインも機能もシンプルでTCAを試すには丁度良い機能だった。

    View full-size slide

  51. 05
 画面構成

    ©NewsPicks Inc. All Rights Reserved. 

    ArchiveView
    (SwiftUI)
    RootViewController
    (UIKit)
    EnterpriseFeedViewController
    (UIKit)

    View full-size slide

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


    View full-size slide

  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
    let archiveRepository: ArchiveRepository
    }
    }
    Environmentはモックで置き換え可能に 

    実装

    プロトコル


    View full-size slide

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

    View full-size slide

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

    View full-size slide

  56. 05
 最初のTCA実装を終えて

    ©NewsPicks Inc. All Rights Reserved. 

    機能がシンプル、かつ他の画面とのインタラクションが無いというのもあり、特にハマるこ
    となく実装できた。

    TCAは問題なかったが、SwiftUIの機能不足を痛感(ScrollViewにRefresh機能がないと
    か)。

    余談だが、この機能を実装している最中にSwitchStoreがリリースされたのはタイムリー
    だった。

    ※自分が出したDiscussionsへの投稿
    https://github.com/pointfreeco/swift-composable-architecture/discussions/590

    大きな課題なく実装できた 


    View full-size slide