dely Tech Blog

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

iOS版クラシルのフィードを滑らかな動きにするためにやったこと

f:id:takaoh717:20191007184951p:plain

こんにちは、iOSエンジニアのtakao(takaoh717)です

今回はクラシルiOSアプリのフィードのパフォーマンス改善を行った話をご紹介します。
改善を行ったフィードはUICollectionViewで構成されており、レシピ、画像バナー、広告など複数の異なる型のデータを表示しているような画面です。

今回行った変更は以下の内容です。

  • 差分更新ライブラリの導入とデータの管理、更新ロジックの変更
  • セルのサイズ計算を事前に行うよう修正
  • 通信時やログ送信時の重い処理をバックグラウンドスレッドで実行

改善前の課題

改善を行う前は、アプリを動かしていると実際に分かるレベルでパフォーマンスに問題がありました。

  • スクロール自体の挙動が若干重くてスムーズじゃない(指の動きに対して若干ひっかかりがある)
  • ページングの読み込みをしたときにスクロールが止まることがある
  • 更新時に画面がチラつくことがある

差分更新ライブラリの導入とロジックの見直し

まず最初に、差分更新ライブラリの導入を行いました。 これまでは、一部分のみ自前のロジックで差分更新を行って、基本的にはreloadData()を多用しているような状態でした。
何度か差分更新を行うようにしたことはあるのですが、更新タイミングによるクラッシュなどが度々発生し、結局reloadData()に戻すようなことをしていました。 そこで、今回のリファクタリングを気にreloadData()を使用しない状態にしておきたかったので、ライブラリを導入しました。

DifferenceKitの導入

差分更新用のライブラリはDifferenceKitを選択しました。

github.com

選定理由では以下の点が判断基準になりました。

  • パフォーマンスの高さ
  • プロジェクトへの導入コスト、実装コストの低さ

パフォーマンスについては実際に計測して比較はしていないのですが、公式のドキュメントに記載されている内容では、RxDataSourcesIGListKitよりも高速になっているようです。

実装面については、classだけではなくstructにも対応している点や、AnyDifferentiableを使って異なる型のデータを一つの配列で管理可能な点がポイントでした。 Examplesに動くアプリのコードが載っているのでそちらとドキュメントを参考にして実装しました。

実装方法

まず、フィードに表示するコンテンツを表すModelDifferentiableを準拠させます。

extension Model: Differentiable {
    public var differenceIdentifier: String {
        return id
    }

    public func isContentEqual(to source: Model) -> Bool {
        return title == source.title
    }
}

今回の実装ではフィードの1列を1Sectionで表現する構造にしてみました。Sectionのデータ管理をしやすいようにDataSourceは以下のような実装にしました。

// Section毎のデータを保持する配列
var dataSources = [Section]()

// Sectionに該当するデータ型を保持するためのenum
enum FeedSectionModel: Differentiable {
    case hoge
    case fuga
    case piyo
}

// 1Sectionを一つのまとまりとして管理するために定義
typealias Section = ArraySection<FeedSectionModel, AnyDifferentiable>

更新時の実装も変更前と後の配列を用意して渡してあげるだけなので、とてもシンプルな実装になります。 また、interruptに特定の条件を渡しておけば、結果がtrueになった場合に差分更新をせずにreloadData()を行うようにできます。
クラシルはUI上の1つのセルのサイズが大きいため、例えば、リストの途中の位置などで一定数以上のセルの挿入があったりすると、スクロール位置などが大きくずれてしまったりするため、一定の個数以上の更新が必要な場合などはreloadData()を行うようにしました。

let new = dataSource
new.append(ArraySection(model: FeedSectionModel.hoge, elements: newData))

// source: 更新前のデータ、target: 更新後のデータ
let changeSet = StagedChangeset(source: dataSources, target: new)

collectionView.reload(using: changeset, interrupt: { $0.changeCount > 3 }) { data in
    dataSource.data = data
}

パフォーマンスに大きく影響を与えている実装箇所の特定

ベースのリファクタリングが出来た後にどの実装が問題になっているのかを特定するため、InstrumentsのTimeProfilerを使って重い処理を特定する作業を行いました。 使い方はとてもシンプルですが、実際にアプリを動かしながら、カクつくタイミングにメインスレッドで実行している重い処理をひたすら確認していきました。

また、今回のようなMainThreadで実行されている重い処理を見たい場合の確認では以下のような設定を使用していました。

f:id:takaoh717:20190925085850p:plain
Instruments

セルのサイズ計算を事前に行う

スクロール自体の挙動が重くてスムーズな挙動にならない場合はfunc collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath)のサイズ計算が問題になっているパターンがあると思います。 実際にクラシルでもレシピや広告などのデータを取得した後に、func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath)の中で表示するCellのサイズ計算を行っていたため、Instrumentsで見てみるとその中身の関数のときに負荷が跳ね上がっていました。

このような、データの内容によってセルのサイズが可変になる場合、データを取得したタイミングで先にサイズ計算を行い、ModelEntityでサイズを保持するようにしました。

そうすることで、Layoutのサイズを返すときには事前に計算しておいたサイズを返すだけになり、メインスレッド上での処理が減るのでパフォーマンスが向上します。

Viewの描画に直接関わらない重い処理の修正

スクロール途中に急ブレーキがかかるようにスクロールが停止したり、次のページの読み込みが走ったタイミングでカクつきが発生したりしていた原因はViewの描画に直接関係のない重い処理をメインスレッドで行ってしまっていたものでした。 この場合も、どの処理がネックになっているかをまずInstrumentsで確認しました。

  • メインスレッドで実行していた重い処理
    • API通信後のJsonのパース
    • ログの送信
    • サーバーから取得したデータの変換処理

これらの処理をDispatchQueueを使って、適切にバックグラウンドスレッドに引き渡すことによって、読み込み時などにメインスレッドの使用率が急上昇することがなくなり、UIのカクつきが解消されました。

まとめ

UICollectionViewはUITableViewに次いで多くのiOSアプリで使われていると思います。 しかし、適切に実装していないとパフォーマンスの低下がユーザに見える形で分かりやすく出てしまうため、適度にメンテナンスすることが大事だと思います。

delyでは様々なポジションのエンジニアを積極採用中です!ご興味がある方はぜひご連絡ください。