dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

クラシルiOSにおけるSwiftUIの実装方針

こんにちは、クラシルiOSのEMの@RyogaBarbieです。

2021年のSwiftUI 3、2022年のSwiftUI 4からSwiftUIを本番で使用するアプリも増えたのではないかと思いますが、 クラシルでも新規開発される画面では積極的にSwiftUIを使用していこうという流れになっています。

今回はSwiftUIを導入するに当たって、チーム内で採用してる実装方針について紹介していこうと思います。
主にWWDCのSwiftUI関連のセッションなどでAppleが公式で発表、推奨しているに則るようにしています。※おすすめのセッションは文末に記載しています。

クラシルの実装方針には

  • Structural Identityを意識した実装
  • 不活性修飾子やmodifier内で三項演算子などをうまく活用する
  • Spacerと.paddingの使い分けの方法
  • データソースを持つViewを1つに限定する
  • Viewで@State, @Bindingを使用しない
  • etc.

などがあります。

今回は、その中でも「データソースを持つViewを1つに限定する」、「Viewで@State, @Bindingを使用しない」の2つに関して、 どのような方針で実装しているのかを書いていこうと思います。

「データソースを持つViewを1つに限定する」

これはWWDCのSwiftUIのセッション内でも言及されているSingle Source of Truthを実現するためのものになります。

クラシルiOSではSwiftConcurrencyベースで実装されている状態管理ライブラリのActomatonを導入しています。(今回の主題とはあまり関係ないので詳細な説明は省きますが)

stateを管理するActomatonのstoreを持つViewを画面ごとに1つに限定しており、
データソースを持つことを明確にするためにも、ScreenViewというsuffixを付ける運用にしています。
(ScreenViewはUIkitにおけるUIViewControllerのような位置付け)

「Viewで@State, @Bindingを使用しない」

「データソースを持つViewを1つに限定する」にも関連してくることですが、
Actomaton.storeでのデータソースの管理と@StateによるViewでのステート管理によって、複数箇所でのデータソースなどを管理をすることになりSingle Source of Truthを実現できなくなります。

SwiftUIのComponentの再利用性を考えた場合、@Stateを@Bindingという形で数珠繋ぎで引き渡していった時に、孫のComponentで親のScreenViewが知らない変更などが起きており、複数の画面で再利用する場合に特定の画面では不整合やバグに繋がるなどのケースも考えられます。

そこで、@Stateの使用を禁止することでActomatonで管理するStateを唯一のデータソースとして、Single Source of TruthとステートレスなViewを実現します。
(ここでいうステートレスは、WWDCで言及されてる「依存」がないということではないです)
それらを実現するために、クラシルで定義&導入しているのがViewDataです。

ViewDataについて

ViewDataはViewをレンダリングするのに必要な情報を構造化したもので、APIから取得したResponseやデータベースから取得した情報などから、Viewのレンダリングに必要な情報だけを抽出したものです。

クラシルはマルチモジュール設計にて運用されており、大きくCoreレイヤー、Featureレイヤー、Appレイヤーがあり、ViewDataはFeature(UI)レイヤーで定義&取り扱うという方針になっています。
画面遷移時に必要な情報などは、Feature間を跨ぐために必要な型を集めたFeatureInterfacesモジュールに定義されている型で行い、Viewのinit時などにViewDataに変換する形です。
(※クラシルでは画面遷移や、Navigation、Tabなどの管理はUIKitベースで行っています)

実装例

TwitterライクなUIでの使用方法をサンプルとして説明します。

自分のツイートが一覧して表示されるようなUIです。
ツイートをTweetItemView, ツイート一覧をTweetListViewとして定義し、TweetListViewでは内部にTweetItemViewを複数個持つような実装になっています。 リツイート、いいね、シェア等のリアクション、+ボタンのタップでモーダルの表示などができます。

ツイートごとにデータをどうやって扱っているか、+ボタンタップ時のモーダルの制御をどうやるか、ScreenViewでの定義を例にしていきます。

ツイート

ツイート

TweetItemViewの表示するのに必要なuserName、userId、tweetText、didLikeなどをTweetItemViewDataとして構造化し定義し、それらを基にViewを描画します。

struct TweetItemViewData: Sendable, Equatable {
    let tweetId: UUID
    let tweetText: String
    let tweetPostedAt: String
    var didLike: Bool
    var didRetweet: Bool

    let userId: String
    let userName: String
    let userImageName: String
}


struct TweetItemView: View {
    let viewData: TweetItemViewData
    let didTapHeartAction: (String) -> Void
    ...

    var body: some View {
        HStack(alignment: .top) {
            UserIconView(
                imageName: viewData.userImageName
            )

            VStack(alignment: .leading) {
                UserSectionView(
                    userName: viewData.userName,
                    userId: viewData.userId,
                    postedAt: viewData.tweetPostedAt
                )

                Spacer()

                Text(viewData.tweetText)

                Spacer()

                ReactionSectionView(
                    didRetweet: viewData.didRetweet,
                    didLike: viewData.didLike,
                    didTapHeartAction: { didTapHeartAction(viewData.tweetId) },
                    ...
                )
            }
        }
    }


struct TweetListView: View {
    let tweetItemViewDatas: [TweetItemViewData]
    let didTapHeartAction: (String) -> Void
    ...

    var body: some View {
        ForEach(tweetItemViewDatas, id: \.tweetId) { viewData in
            TweetItemView(
                viewData: viewData,
                didTapHeartAction: didTapHeartAction,
                ...
            )
        }
    }
}

didTapHeartActionというハートアイコンをタップした時に行うClosureの中身には、データソースを更新するアクションを渡します。

didTapHeartAction: {
  viewModel.didTapHeartAction()
}

呼び出したアクション内でViewDataが更新されると、更新されたViewDataに依存しているViewがSwiftUIによって再レンダリングされていきます。

ScreenViewData

ScreenView内の全てのViewをレンダリングするのに必要な依存をScreenViewDataとして定義しています。 ScreenView内で表示されている子Viewのレンダリングに必要なViewDataも含めて定義され、ScreenViewをレンダリングするのに必要な全ての情報を構造化、階層化したものになります。

struct TimelineScreenViewData: Sendable, Equatable {
    // Tweetの一覧
    let tweetItems: [TweetItemViewData]
    // apiの通信状態によってローディングを表示するか
    var isDisplayLoading: Bool = true
    // モーダルの表示の制御
    var isPresentedTweetView: Bool = false
}

ViewModelなどで実装する場合は、
Viewのレンダリングに必要のない状態などはViewModelのpropertyなどで持たせ、ScreenViewDataのみ@Publishedで公開するような形になるかと思います。

前述の通り、クラシルiOSでは画面遷移などはUIKitベースで行っていますので、 isPresentedTweetViewなどの状態はViewDataでは管理していません。

モーダル

SwiftUIでのモーダルは、.sheetの引数のisPresentedは、Bindingによってモーダルの表示非表示を制御しています。
以下は、ViewModelのscreenViewDataにモーダルの表示状態を管理する変数としてisPresentedTweetViewを定義し、isPresentedTweetViewの状態によってモーダルの表示制御する例になります。
@State, @Bindingの使用を禁止しているため、.sheetやTextFieldなどでBinding<>を使用した双方向バインディングが実装する場合、
Binding.init(get:set:)を使用して実装する形になります。

struct TimelineScreenView: View {
    var body: some View {
        ScrollView {
            TweetListView(
                tweetItemViewDatas: vm.screenViewData.tweetItems,
                ...
            )
            // ここに注目
            .sheet(isPresented: Binding.init(get: {
                vm.screenViewData.isPresentedTweetView
            }, set: { bool in
                vm.setIsPresentedTweetView(bool)
            })) {
                /// ツイート入力フォーム
                TweetView(...)
            }
        }
    }
}

get:に対しては、モーダルの表示非表示の状態を表しているisPresentedTweetViewの値を、
set:に対しては、モーダルを閉じるためのスワイプアクションなどを実行した時に呼ばれるboolの値を受け取り、viewModelなどでviewDataを更新します。
※実際にはクラシルはActomatonベースで実装しています。

終わりに

クラシルにおけるSwiftUIの実装方針について書かせてもらいました。 宣言的UIであるSwiftUIを用いた実装方針については色々ありますが、クラシルではViewDataを定義しSingle Source of Truthを実現しています。 SwiftUIの実装方針に関してはまだまだ手探りでやってますので、SwiftUIでの開発したいという方をお待ちしてます!

!!採用ページはこちら!!

careers.dely.jp

おすすめのセッション

developer.apple.com

developer.apple.com