Edited at

iOSのリッチなTableViewレイアウトにおける滑らかな表示とメモリ最適化のTips

この記事は

「DeNAその2 Advent Calendar 2018」

9日目の記事です。

こんにちは。DeNAの田坂(@yuuuutsk) です。

リーンインキュベーション事業部,クンカブルという犬専用SNSのエンジニア。

エンジニア二人体制なので基本なんでも屋です。


1. 概要

以下の画像のようなリッチなViewをUITableViewなどで大量に表示する時にカクツキが発生すると思います。Texture(旧AsyncDisplayKit)などの非同期UI生成ライブラリを用いて60FPSで動作させる時に困ったことを書きたいと思います。

Image from iOS.png


1.1 まず結論


  • ASTableNodedequeueReusableCell の様な機能がなく無限にスクロールしていくと消費メモリが多くなる。

    工夫が必要。 詳しくは下で解説。


  • Texture などの非同期UIライブラリは最終手段。まずは基本的な最適化観点を潰していく。

    レイアウトをコードで定義するのでメンテナンス性が下がります。


  • 正しく使えばFPS60の気持ちいUXを提供できます!



2. UITableViewをヌルヌル動かせるための基本アプローチ

Texture(詳細は後述)などの非同期UIライブラリは最終手段です。

まず、効率化できるところがないか確認しましょう。

前提として、メインスレッドの処理をなるべく少なくします。

メインスレッドでの一つの処理が長いとUIスレッドがロックされ画面がカクツク原因になります。

以下の改善できる箇所を上から順に確認します。


  1. 無駄なコンポーネントがないか → 無駄にUIViewが多いと多くのメモリを消費します。StackView を利用するなど効率化しましょう。

  2. 無駄な画像が多くないか → 画像サイズが無駄に大きくないか、また隣り合った画像などまとめられる画像は一つに

  3. 高さ計算を非同期で行ってるか → func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat この関数もメインスレッドで呼ばれるのでAPIからデータとった時点など事前に計算しておく。UITableViewAutomaticDimensionは使わない。

  4. コストが高いコンポーネントを可能な範囲で削減 -> UILabelNSAttributedStringは地味にコストかかる

  5. Cellを分割してみる →
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell での一回の処理時間を短くすれば大きなカクツキを防ぐことができます。
    0.02sec以上処理がかかるとだいたいカクツキます。

  6. Color Blended Layers -> 重なり合ったLayerの色を合わせてレンダリングするので計算量が増える。

これらを全て試してもカクツキそうなら、非同期UIライブラリを使うと良いと思います。

参考文献

グラフィックス描画性能の測定 | Apple

非効率なレンダリングの可視化手順の動画 (日本語の文献忘れてしまった。。)

UITableViewの軽量化

レガシーなアプリケーションの 60fps化を目指す為にやっていること


3. Texture

UIView系はメインスレッドでしか生成できません。なので思いセルをUIViewで作るとカクついてしまいます。

一方でCALayerは非同期で生成することができます。

自前でCALayerを使ってゴリゴリUIを実装するのは骨が折れるので

Textureを使いましょう。

TextureはAutoLayoutが使えず、LayoutSpecというクラスを使ってレイアウトを表現します。

Layout Examples

この記事では具体的なTextureの使い方は記述しません。

いくつか記事があると思うので探してみてください


UI構成時の注意

GUIビルダーがないので全てコードで定義する事になります。開発の基本ではありますが、わかりやすい単位でモジュール化することが大事になってきます。


UIデバッグ時の効率化

上にも書きましたが、GUIで構築できないのとStack系を使うと、XcodeのDebug View Hierarchyでも確認できない部分などがあります。

コード修正→ビルド→また修正を繰り返すと結構時間かかるので、TextureじゃなくてもリアルタイムにUI調整ができる Revealを試して見てください。

https://revealapp.com/


UIViewと共存

全てのViewをTextureにするとものすごいコストがかかると思います。

SDK使った広告表示など、以下のように部分的にUIViewと共存することができます。

let nodeBlock: ASCellNodeBlock = { [weak self] _ in

let cellNode = ASCellNode { [weak self] () -> UIView in
# UITableViewCellも扱える
let cell = self!.node.view.dequeueReusableCell(withIdentifier: "AdTableViewCell") as! AdTableViewCell
cell.delegate = self
cell.clipsToBounds = true
return cell
}
# 背景やサイズを指定
cellNode.backgroundColor = AppColorRealWhite
cellNode.style.preferredSize = size
return cellNode
}

参考文献

iOSアプリのUX改善!FacebookのAsyncDisplayKitで60FPSのハイパフォーマンスなiOSアプリを作る

Reveal.appめっちゃ良い!


3.1 Textureの特徴と注意すべき点


メモリ消費

Textureでは非同期でUIを生成しますが、スレッドも複数使われるためその分短時間で多くのメモリが使われます。


ASTableNodeでのセル大量読み込み

UITableViewのTexture実装であるASTableNodeは、dequeueReusableCellの様にセルが使いまわされないので、無限にスクロールしていくと使用メモリがたまりそのうちOSにKillされます。。

もし、ASTableNodeを使う場合は一気に大量のNodeを生成しない様に、ページング機能を実装しましょう。

クンカブルのセルみたいな物を数百件一気にloadするとえらいことになります^^;


排他処理

UITableViewのノリで実装すると、MutexLockしてなくて変な挙動をすることがあります。

同時にアクセスされて困る変数はMutexLockしましょう。


4. 解決策


方針

dequeueReusableCellのような挙動を再現する。表示しているセルだけメモリに乗せて非表示になったタイミングでメモリから解放する。(ちょっと工夫しないとメモリ昇竜拳食らう)


dequeueReusableCellのような挙動を再現する

単にメモリから解放して、セルの高さを0にしてしまうと、ガクッとスクロールが動いてしまします。

なので、同じサイズの空のASDisplayNodeと差し替えます。

構造も割とシンプルなのでコードで説明


func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {

# ダミーセルの高さが存在すれば、ダミーセルを生成する
if let heightCache = heightCaches[indexPath.row], heightCache > 0 {
return createDummyCell(heightCache: heightCache, row: indexPath.row)
}
let post = posts[indexPath.row]
let nodeBlock: ASCellNodeBlock = {
let cell = PostCellNode(postModel: post)
return cell
}
scrollingToTop = false
return nodeBlock
}

func tableNode(_ tableNode: ASTableNode, didEndDisplayingRowWith node: ASCellNode) {
guard let indexPath = node.indexPath else { return }
# 本来のセルを非表示にする
hideCell(indexPath: indexPath)
}

func tableNode(_ tableNode: ASTableNode, willDisplayRowWith node: ASCellNode) {
guard let indexPath = node.indexPath else { return }
# 本来のセルを表示する
showCell(indexPath: indexPath)

if indexPath.row == posts.count - 1 {
appendPosts()
}
}

# ダミーセルの生成
func createDummyCell(heightCache: CGFloat, row: Int) -> ASCellNodeBlock {
let height = heightCaches[row] ?? 0.0
let width = node.bounds.width
let nodeBlock: ASCellNodeBlock = {
let cell = DummyCellNode()
cell.style.preferredSize = CGSize(width: width, height: height)
return cell
}
return nodeBlock
}

func showCell(indexPath: IndexPath) {
if scrollingToTop { return }
guard let _ = self.node.nodeForRow(at: indexPath) as? DummyCellNode else { return }
var heightCaches = self.heightCaches
heightCaches[indexPath.row] = 0.0
self.heightCaches = heightCaches
self.node.performBatchUpdates({ [weak self] in
self?.node.reloadRows(at: [indexPath], with: .none)
})
}

func hideCell(indexPath: IndexPath) {
if scrollingToTop { return }

guard let cell = node.nodeForRow(at: indexPath) else { return }
let height = cell.frame.height

node.performBatchUpdates({ [weak self] in
if var heightCaches = self?.heightCaches {
heightCaches[indexPath.row] = height
self?.heightCaches = heightCaches
}
self?.node.reloadRows(at: [indexPath], with: .none)
})
}


ScrollToTop対策

この対策をしないと大量のセル生成と解放が同時に同時に行われます。

ScrollToTopをされたらフラグを立て2ページ目以降を切り捨てて、reloadData()します

これにより、2ページ目以降は再レンダリングされないのでパフォーマンス効率化することができます。

フラグが立ってる時はダミーセルに関する処理はしません。そして、一番上のセルが再レンダリングされた時にフラグを下げます。


結果

通常のASTableNodeと表示してないセルをダミーセルに取り替えるサンプルコードを作って、パフォーマスを比較をしました。

一つのセルに大量のASTextNodeを乗せてメモリをたくさん使う構造にしています。

サンプルコード

https://github.com/yuuuutsk/ASTableNodeSample


効率化前のASTableNode

normal.png

数分使うとKillされます


効率化後のASTableNode

partial.png

劇的に改善されたと思います!Textureは威力のある武器なので使い方を間違わないようにしましょう。

以上です。読んでくれてありがとうございました。

こうする方がいいぞとかまさかりなど気軽に投げてください〜