iOSクライアントのホーム画面まとめ

Mercari Advent Calendar 2020 の20日目です。

本日は、Personalization TeamでiOS Clientを担当しているmasaki.hagaがお送りします。

2020年4月、MercariのHome画面が新しく生まれ変わりました。リリース当初は旧ホーム画面とほとんど見分けがつかないぐらい似ていましたが、その後、様々なABテストを行い旧ホーム画面にはなかった便利な機能が追加されています。

旧ホーム 新ホーム

旧ホーム画面のタイムラインは、バナー、いいねした商品コンポーネント、おすすめの商品の一覧という構成になっていました。新しいホーム画面では、コンポーネントの概念を一般化し、より多様なレイアウトのコンポーネントを追加し、サーバー側でそれらのコンポーネントの出し分け、内容やレイアウトを操作することができるようになりました。ホームのコンテンツを操作することで、各指標がどのように変化するかを評価し、最適化していくことを目的としています。 たとえば、

コンポーネントの種類 レイアウト
閲覧した商品からおすすめを表示するコンポーネント
お客様の出品状況を表示するコンポーネント
お客様のMerpay残高を表示するコンポーネント

などでABテストを行い、メルカリのファーストビューに表示する最適なコンポーネントの選定を行いました。 この投稿では、そのHome画面のiOS側の設計について説明します。

ホーム画面の設計

ホーム画面は一番お客様の目につきやすい画面であるため、各チームからの需要が高く、それぞれ独自のコンポーネントを開発することが想定されます。そのような画面で、十分な拡張性、保守性、なめらかなUXを実現するために以下の点を念頭に設計されています。

二段階のAPIアクセスを行う

クライアントはホーム画面で表示するデータをホームマイクロサービスから取得しますが、各コンポーネントで表示する具体的なデータはホームマイクロサービスがプロキシとして働き、他のマイクロサービスから取得します。そのため、すべてのデータを一つのAPIコールでバックエンドから取得するようにすると、Viewのインスタンスの生成から最終的なインターフェイスの描画までの時間がもっとも遅いマイクロサービスのレスポンスに依存することになります。

このような依存を取り除くため、APIアクセスは二段階に分けて設計されました。1段階目のAPIコールではどのコンポーネントを表示するかについてのレイアウト情報のみを取得し、クライアントは各コンポートのデフォルトのサイズを用いてレイアウトを計算します。そして各コンポーネントが表示されるタイミングでコンポーネントそれぞれがAPIコールを行いコンテンツを取得します。 たとえば、ファーストビューに入ってくるようなコンポーネントは1段階目のAPIコールが終わった直後にコンテンツが取得されますが、スクロールの下の方に配置されているコンポーネントは、ユーザーがスクロールを行いコンポーネントが表示されようとするタイミングまでデータ取得を遅延します。

各コンポーネントはUIViewControllerのサブクラスとして定義する

各コンポーネントは通信、永続化、レイアウト、ナビゲーションなど様々なロジックを持ちえます。ホーム画面のベースはUICollectionViewですが、各セルをUIViewControllerにして各コンポーネントのロジックはそのUIViewController内にカプセル化しています。

ですので、各コンポーネントのコンテナーとなる親のUIViewControllerはホーム画面のレイアウトと画面全体の状態の保持のみを行いますので新しいコンポーネントを追加しても非線形的にコード量が増えることを阻止できます。

Difference Updateを行う

各コンポーネントはそれぞれ状態を持ち、ユーザーのインタラクションやAPIアクセスによりそれらの状態は変化していきます。各コンポーネントの状態が変化したとき、またはコンポーネントの削除、追加が行われたとき、その差分だけをアニメーションで描画し直すため、DifferenceKitを利用しています。差分更新はUIKitでもiOS13からサポートされていますが、Mercariは現在iOS12もサポートしていますので、DiffereceKitを利用しています。

一度計算したサイズのキャッシングを行う

ホーム画面では標準のUICollectionViewとUICollectionViewFlowLayoutを利用していますが、セルフサイジングの機能は使わずにUICollectionViewDataSourceのメソッドでコンポーネントのサイズをレイアウトに渡しています(セルフサイジングを利用しなかった理由は後述します)。

レイアウトの計算はメインスレッドで行われUXに影響がでやすいため、どんな複雑なレイアウトを追加してもレイアウトの計算を最小限に抑えられるような仕組みが必要です。安直な実装では、コンポーネントがUICollectionViewからデキューされる度にコンポーネントのサイズを計算しますが、それは無駄が多い方法です。各コンポーネントの状態にハッシュ値をもたせて、ハッシュ値から一度計算したサイズを取り出してくるようにしています。コンポーネントの状態が変わればハッシュ値も変わりますので、コンポーネントのサイズは再計算されます。

状態によらず一定のサイズをもつコンポーネントには、クラス名などから固定のハッシュ値を作ります。

/// ComponentState is a protocol for each components' state type
public protocol ComponentState: Differentiable, SizeHashable { }

public protocol SizeHashable: Hashable {
    var sizeHashValue: Int { get }
}

public extension ComponentState {
    var sizeHashValue: Int {
        return hashValue
    }
}

UIScrollViewのContentOffsetは保存する

いくつかのコンポーネントは横方向のUIScrollViewを持っています。あるコンポーネントのインスタンスが別のコンポーネントに再利用された時に、前の状態のUIScrollViewのContentOffsetのままだと、体験に一貫性がなくなりバグとなります。

各コンポーネントの状態にはidentifierが持たせてあるので、そのidentifierでコンポーネントのscrollViewのOffsetを保存するようにして、コンポーネントがデキューされる時に前に保存されたContentOffsetはないかどうかをチェック、あれば前のContentOffsetを反映するようにしています。

機能を切り離してAPIを定義する

これら

  • Difference Updateを行う
  • 一度計算したサイズのキャッシングを行う
  • Scrollのオフセットは保存する

という3つの機能は汎用性が高く、他のビューでも使うことができるため、UICollectionViewDiffableDataSourceと似たようなAPIComponentDataSourceとして切り出しています。

let componentDataSource = ComponentDataSource(collectionView: collectionView, delegateForwardingTarget: self, cellBuilder: { (collectionView, indexPath, component, context) -> UICollectionViewCell in
            switch component {
            case .component0(let state):
                // dequeue cell from collection view
                return cell

            case .component1(let state):
                // dequeue cell from collection view
                return cell
            }
        })

ローディング状態を作り込む

前述の通り、各コンポーネントのコンテンツは表示されるタイミングで初めて取得されますので、通信状態によってはコンポーネントの実際の内容を表示するのに時間がかかってしまうことがあります。

各コンポーネントにはローディング状態のUI(スケルトンビュー)が定義してあり、APIアクセス中に表示することでロード前とロード後のコンテンツの視差を少なくしています。

ロード中の状態

困ったところ

目新しい技術は特に使っていないのであまり困るような場面はありませんでしたが、その中でも特に工夫したところなどを説明します。

セルのAuto sizingが使えなかった

前述の通り、UICollectionViewDataSourceのメソッドでコンポーネントのサイズをレイアウトに渡しているのですが、開発当初はAuto Sizingを利用することでサイズを計算するコードを省略する予定でした。ただ、このAuto SizingとDifference Updateを組み合わせたときに差分更新のアニメーションが乱れるというどうしても解決できないバグがあり、シンプルにサイズを計算しキャッシングしておく仕組みに変更しました。(iOS14登場以前の不具合であるため、現在は修正されているかもしれません。)

タブ型に変更したときにパフォーマンスが落ちた

新しいホーム画面は初期バージョンが2019年10月ごろにリリースされていたのですが、お客様の再訪率が下がったという理由から一度クローズになり、旧ホーム画面により近い複数のタブを持った画面に再設計されました。

お蔵入りになった新ホーム

現在のホーム画面は、このお蔵入りになった新ホームをUIPageViewControllerを使って複数設置しているのですが、タブからタブへの移動でのパフォーマンスが非常に悪いことがわかりました。

これは、一つのビューコントローラーにたくさんのビューコントローラーが内包されていることが原因でした。メリカリのiOSクライアントはマイクロビューコントローラーという概念(詳細についてはmercari/Mew#Referenceを参照してください。)にしたがって設計されていて、各コンポーネント内に配置されている商品サムネイルも画面遷移やログ送信のロジックを内包するためにビューコントローラーとして設計されています。

そのため、各タブが多数のビューコントローラーを持ちますが、タブを変更しようとするタイミングでビューのAppearanceMethod(viewWillAppearなど)が全ての子ビューコントローラーに対して呼ばれてしまいパフォーマンスが落ちることがわかりました。

一部の子ビューコントローラーはUIの一部として使われることに特化してデザインされているので、全てのビューコントローラーに対して、このAppearanceMethodを呼ぶ必要はありません。このAppearanceMethodを子ビューコントローラーに自動的にフォワードすることを制御するAPIshouldAutomaticallyForwardAppearanceMethodsを設定することで、パフォーマンスが劇的に改善し、素早く左右にスワイプしても滑らかに表示することが可能になりました。

public override var shouldAutomaticallyForwardAppearanceMethods: Bool {
    return false
}

ホーム画面の応用

ホーム画面の一部の機能はAPIとして切り出されているのですが、汎用性の高さから、検索画面、お知らせ画面、保存した検索一覧画面など複数の画面にも使われています。特に検索画面ではホーム画面同様に様々なコンポーネントを試すことが多いのですが、新しいアーキテクチャを導入してからはスケーラビリティやメンテナビリティを確保しながら高速にコンポーネントを追加してテストすることが可能になりました。


メルカリではミッション・バリューに共感できるiOSエンジニアを募集しています。一緒に働ける仲間をお待ちしております。

明日のMercari Advent Calendar 2020執筆担当は、 ….rina…. Othersさんです。引き続きお楽しみください。