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


この記事は
「DeNAその2 Advent Calendar 2018」
9日目の記事です。

こんにちは。DeNAの田坂(@yuuuutsk)  です。
リーンインキュベーション事業部,クンカブルという犬専用SNSのエンジニア。
エンジニア二人体制なので基本なんでも屋です。

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

![Image from iOS.png](https://qiita-image-store.s3.amazonaws.com/0/29037/82fd8425-b56b-33c6-781f-ceeb07f0b41c.png)


## 1.1 まず結論
- `ASTableNode`は `dequeueReusableCell` の様な機能がなく無限にスクロールしていくと消費メモリが多くなる。
**工夫が必要。** 詳しくは下で解説。

- `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. コストが高いコンポーネントを可能な範囲で削減 -> `UILabel`や`NSAttributedString`は地味にコストかかる
5. Cellを分割してみる → 
`func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell` での一回の処理時間を短くすれば大きなカクツキを防ぐことができます。
0.02sec以上処理がかかるとだいたいカクツキます。
6. Color Blended Layers -> 重なり合ったLayerの色を合わせてレンダリングするので計算量が増える。

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


参考文献
[グラフィックス描画性能の測定 | Apple](https://developer.apple.com/jp/documentation/DeveloperTools/Conceptual/InstrumentsUserGuide/MeasuringGraphicsPerformance.html#//apple_ref/doc/uid/TP40004652-CH32-SW1)

[非効率なレンダリングの可視化手順の動画](https://www.youtube.com/watch?v=gzO-SWYAUQQ) (日本語の文献忘れてしまった。。)


[UITableViewの軽量化](https://qiita.com/noppefoxwolf/items/3109857f60e25816e55d)
[レガシーなアプリケーションの 60fps化を目指す為にやっていること
](https://speakerdeck.com/satoshin21/regasinaapurikesiyonfalse-60fpshua-womu-zhi-suwei-niyatuteirukoto?slide=46)

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

一方でCALayerは非同期で生成することができます。
自前でCALayerを使ってゴリゴリUIを実装するのは骨が折れるので
[Texture](https://github.com/TextureGroup/Texture)を使いましょう。

TextureはAutoLayoutが使えず、LayoutSpecというクラスを使ってレイアウトを表現します。
[Layout Examples](https://texturegroup.org/docs/automatic-layout-examples-2.html)

この記事では具体的なTextureの使い方は記述しません。
いくつか記事があると思うので探してみてください

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

## UIデバッグ時の効率化
上にも書きましたが、GUIで構築できないのとStack系を使うと、Xcodeの`Debug View Hierarchy`でも確認できない部分などがあります。
コード修正→ビルド→また修正を繰り返すと結構時間かかるので、Textureじゃなくても`リアルタイムにUI調整ができる` Revealを試して見てください。

https://revealapp.com/

## UIViewと共存
全てのViewをTextureにするとものすごいコストがかかると思います。
SDK使った広告表示など、以下のように部分的にUIViewと共存することができます。

```swift
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アプリを作る](https://medium.com/eureka-engineering/ios%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AEux%E6%94%B9%E5%96%84-facebook%E3%81%AEasyncdisplaykit%E3%81%A760fps%E3%81%AE%E3%83%8F%E3%82%A4%E3%83%91%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%B3%E3%82%B9%E3%81%AAios%E3%82%A2%E3%83%97%E3%83%AA%E3%82%92%E4%BD%9C%E3%82%8B-4e3daf6c0b09)

[Reveal.appめっちゃ良い！](http://nerd0geek1.hatenablog.com/entry/2016/03/05/163243)

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

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

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

もし、ASTableNodeを使う場合は一気に大量のNodeを生成しない様に、ページング機能を実装しましょう。
クンカブルのセルみたいな物を数百件一気にloadするとえらいことになります^^;

### 排他処理
UITableViewのノリで実装すると、MutexLockしてなくて変な挙動をすることがあります。
同時にアクセスされて困る変数はMutexLockしましょう。

# 4. 解決策

## 方針

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

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

単にメモリから解放して、セルの高さを0にしてしまうと、ガクッとスクロールが動いてしまします。
なので、同じサイズの空のASDisplayNodeと差し替えます。

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

```swift

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
<img width="840" alt="normal.png" src="https://qiita-image-store.s3.amazonaws.com/0/29037/09119b60-a229-baab-41ea-63df40f70190.png">

数分使うとKillされます

### 効率化後のASTableNode
<img width="853" alt="partial.png" src="https://qiita-image-store.s3.amazonaws.com/0/29037/6f11905d-c090-8b33-c410-e7daa0ce6b00.png">

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



以上です。読んでくれてありがとうございました。
こうする方がいいぞとかまさかりなど気軽に投げてください〜