[Swift]脱AutoLayoutでTableViewのスクロールを滑らかにする

# 前書き
上司から画面（TableView）スクロール時の**カクツキ**を兼ねてから言われていたので、その改善に着手することに。

そして先日、
[**AKIBA.swift × エウレカ コードレイアウト勉強会**](https://classmethod.connpass.com/event/81032/)
に参加したのがきっかけで、**脱AutoLayout**を行ってみた次第です。
(ためになる資料が公開されているので是非目を通して見てください)

----

# 改善

脱AutoLayout以外にも行ったものも合わせて掲載しておきます。

----

# 前提

以下の中にある事例が、いくつかが当てはまりそうだったのでやってみた。

- [UITableViewのスクロールを滑らかにする８の方法](http://mokyu.game-ss.com/it%E9%96%A2%E9%80%A3/uitableview%E3%81%AE%E3%82%B9%E3%82%AF%E3%83%AD%E3%83%BC%E3%83%AB%E3%82%92%E6%BB%91%E3%82%89%E3%81%8B%E3%81%AB%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95)
- [UITableViewを滑らかにスクロールさせるための確認すべきポイントまとめ](https://qiita.com/teakun/items/b280c21b4916cf641799)

結果、目に見える改善はあまり見られなかったのですが笑
（そもそもほとんど最初から適応していた）

----

# 対策①**UILabel** → **UITextView** に変更

**UILabel** → **UITextView** に変更した部分は若干滑らかになりました。
（メディアアプリなので、テキストが500~700文字を突っ込むこともざらにある）

----

こういう質問をしている外人もおられますし、
- [**iOS UI best practice - UILabel or UITextView?**](https://stackoverflow.com/questions/18070484/ios-ui-best-practice-uilabel-or-uitextview)

これのベスト回答によれば、アップル公式に書いてあるとのことで
- [**Displaying Text Content in iOS**](https://developer.apple.com/library/content/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/UsingTextClasses/UsingTextClasses.html)

以下引用
> Although these classes actually can support the display of arbitrary amounts of text, labels and text fields are intended to be used for relatively small amounts of text, typically a single line. Text views, on the other hand, are meant to display large amounts of text.

----

つまり
- UILabel → 短いテキスト
- UITextField → 短いテキスト
- UITextView → 長いテキスト

にサポートしてるよってことで！

**UILabel** に**長文を突っ込む**のはやめましょう笑

----

# 対策②**セルの高さを事前に計算する**
もともと、画像を扱うセルのみ対応を行っていたが、パーツが単純で高さの計算がしやすいXIBのセルも計算するようにしました。
根本的にAutolayoutで描画が崩れる（高さが取られない問題）等を解決できる（ことが多い）ので、やっておくことをオススメします。

- [Autolayout で UITableViewCell の高さを設定すると reloadData 後にカクカクすることがある](https://qiita.com/usagimaru/items/664d0d67fe6f8f7fafed)
- [見積もりの高さでUITableViewを高速化する話](https://qiita.com/glayash/items/92863bedc734eaa8e7ed)

----

#### **実装**

自社アプリでは、以下の記事方針を参考にしました。
- [Autolayoutを使ったUITableViewCellの高さ計算を「手動」で行う実装](https://qiita.com/paming/items/15a849c6933bc8dc6a6c)

記事内では、高さの計算処理を``heightForRow``で行っているのですが、**heightForRowで重たい処理をすべきではない**ので（ただ値をセットするだけが理想なので）

記事を参考にしつつ以下の流れをとりました。
①APIでデータ取得
②ダミーのセルを生成（for文とかでモデルごとに回す）
③ダミーのセルで高さを計算
④計算したものをViewModelに保持（全てのモデルごとに）
⑤TableViewのreloadで描画
⑥heightForRowでViewModelから高さをセット

----

#### **ポイント**
**TableViewのreloadが走る前**に**すべて計算しておく**ということです。
``heightForRow``や``cellForRow``内でのコストを極力抑えることが、高速化に近づきます。

----

#### **コード例**

具体的に簡単なコード例を出しておく。
以下は、ただテキストを表示するためだけのセル

<img width="352" alt="スクリーンショット 2018-04-09 1.08.48.png" src="https://qiita-image-store.s3.amazonaws.com/0/175242/6f432794-167f-0163-41eb-410cd3e5555a.png">

----

```swift
/* 
 テキストの高さを計算するためのもの
 他のセルでも使用するのでプロトコルとして切り出している
*/
protocol TextCalculatable {
    func calculateTextHeight() -> CGFloat
}

// MARK: - Class
class TextTableViewCell: UITableViewCell {

    // 実際に描画されるテキストの部分
    @IBOutlet fileprivate weak var textView: UITextView!

    /* いろいろ中略 */
}

// MARK: - TextCalculatable
extension TextTableViewCell: TextCalculatable {

    // テキストの高さを横幅から計算する
    func calculateTextHeight() -> CGFloat {
        return textView.sizeThatFits(
            CGSize(width: UIScreen.main.bounds.width-margin*2, // 左右のマージンを引く
                   height: .greatestFiniteMagnitude)
            ).height
    }

}
```

実際は、セルによっては複数の要素の高さを計算する必要があるので、もっと気持ち悪いコードになるかと思います笑

----

# 対策③**脱Xibのセルにする**

**Instruments**を使用して、処理の重たい関数を探してみた。

- [Instrumentsを使ってみた](https://qiita.com/nohirap/items/39ed06c550b6b5c1f247)
- [InstrumentsのTime Profilerを使って重たいメソッドを特定する](http://tech-gym.com/2012/04/ios/732.html)

ここから、重たいメソッドは１つ

＿人人人人人人人人人人人人人人＿
＞　**cellForRowの処理が重い**　＜
￣Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y￣

----

### **どこが重い？**
cellForRowで行っていることと言えば
- セルのXib読み込み
- セルの中身をセット

この二つのみ。

----

#### **アプリの現状**

配信しているアプリは、**元々WEBメディアとして3年運営**していたものをフルネイティブ化している
→**アプリに最適な構造になっていない**

特にAPIで返ってくるものをタイプ分けすると、**セルのタイプが25個**もあった。
→**読み込むセルが（Autolayoutを使用したXIBが）最大25個ある**（１画面でのはなしです！！！）

----

ここで1つの仮説がたった。
**複数のXIB読み込みはコストがでかいのでは？**

毎度cellForRowで判定しだし分けしていたため、いくら使い回してるとは言え重たそう、、。
ということで、重たいセルに絞って脱Xib化しました。

参考
- [Interface Builderに依存しないiOS開発のススメ](https://medium.com/eureka-engineering/interface-builder%E3%81%AB%E4%BE%9D%E5%AD%98%E3%81%97%E3%81%AA%E3%81%84ios%E9%96%8B%E7%99%BA%E3%81%AE%E3%82%B9%E3%82%B9%E3%83%A1-13811b9236d5)

----

# 対策④**脱AutoLayoutのセルにする**

やっとタイトルの本題です！
①〜③まで行ったが、まだ微妙にカクツキが残っていた。

----

ここで勉強会でのことを思い出しました。以下参考。
- [『Akiba.swift × エウレカ』で「AutoLayout以外の選択肢」について発表しました](https://medium.com/eureka-engineering/akiba-swift-%E3%82%A8%E3%82%A6%E3%83%AC%E3%82%AB-%E3%81%A7-autolayout%E4%BB%A5%E5%A4%96%E3%81%AE%E9%81%B8%E6%8A%9E%E8%82%A2-%E3%81%AB%E4%BB%98%E3%81%84%E3%81%A6%E7%99%BA%E8%A1%A8%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F-b8c9460ce1e4)

上記の[ブログ](https://medium.com/eureka-engineering/akiba-swift-%E3%82%A8%E3%82%A6%E3%83%AC%E3%82%AB-%E3%81%A7-autolayout%E4%BB%A5%E5%A4%96%E3%81%AE%E9%81%B8%E6%8A%9E%E8%82%A2-%E3%81%AB%E4%BB%98%E3%81%84%E3%81%A6%E7%99%BA%E8%A1%A8%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F-b8c9460ce1e4)内にも出てきますが、

<img src="https://cdn-images-1.medium.com/max/1600/1*Nu-wY2Lb0DlXrDJZWaF_yw.png" width=70%>
[引用](https://github.com/layoutBox/FlexLayout#performance)

----

細々ありますが、
- **Autolayoutはコストが高い**
- **StackViewはなお高い**

のです。（注：他の線はAutoLayoutを使用しないライブラリです）

上記のことから
「**AutoLayout**」 = 「**重い**」
という結論に至りました。

ということで脱AutoLayoutに。

----

### **実装**
先のグラフ及び、上記の[ブログ](https://medium.com/eureka-engineering/akiba-swift-%E3%82%A8%E3%82%A6%E3%83%AC%E3%82%AB-%E3%81%A7-autolayout%E4%BB%A5%E5%A4%96%E3%81%AE%E9%81%B8%E6%8A%9E%E8%82%A2-%E3%81%AB%E4%BB%98%E3%81%84%E3%81%A6%E7%99%BA%E8%A1%A8%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F-b8c9460ce1e4)内で
**「AutoLayoutではないレイアウトエンジン」**のライブラリがいくつか記載されていますが、

- １週間ほどで結果を残す必要があった（mustではないが一応リリースサイクルもそんな感じなので）
- 25個のセル全てを描き直す時間がない

ということで、ライブラリを使わず自前で実装しました。
また重たいセル（動的に高さが変わるもの）に絞って書き直しを行った。

----

#### **How To ?**

単純な話ですが、AutoLayoutが行っていた処理を**自分で行う**だけです。
CGRectを使って自分でframeを操作していきます。

①セルのインスタンス生成
②CGRectでフレームを操作

----

#### **コード例**

先ほどの「対策②セルの高さを事前に計算する」で出てきた、テキストを表示するためだけのセルです。
プロトコルに関しては、直接の趣旨には関係ないので、無視していただいて構いません。

```swift
/* セルのセットアッププロトコル */
protocol ItemBindable {
    func setupCell(with viewModel: ItemViewModel)
    func calculateHeight() -> CGFloat
}

/* 高さ計算のプロトコル */
protocol TextCalculatable {
    func calculateTextHeight() -> CGFloat
}
```

----


```swift
// MARK: - Class
class TextTableViewCell: UITableViewCell {
    
    // MARK: Variable
    fileprivate let margin: CGFloat = 15.0
    
    // 描画するテキストを先にセットアップしておく
    fileprivate let textView: UITextView = {
        let text = UITextView(frame: .zero)
        text.textAlignment = .left
        text.backgroundColor = .white
        text.isEditable = false
        text.layer.borderWidth = 0
        text.dataDetectorTypes = [.link, .address]
        text.isScrollEnabled = false
        text.textContainer.lineFragmentPadding = 0
        text.textContainerInset = .zero
        return text
    }()
    
    // MARK: Override Mehtods
    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        
        // テキストの描画
        contentView.backgroundColor = .white
        contentView.addSubview(textView)
    }
    
    required init(coder aDecoder: NSCoder) {
        fatalError("---- fatalError TextTableViewCell ----")
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        
        textView.text = nil
    }

}

// MARK: - ItemBindable
extension TextTableViewCell: ItemBindable {
    
    // セルのセットアップメソッド。cellForRowで呼ぶ。
    func setupCell(with viewModel: ItemViewModel) {
        // テキストのセット
        textView.attributedText = viewModel.attrData 
        
        textView.frame = CGRect(
            origin: CGPoint(x: margin,
                            y: margin),
            // totalCellHeightはあらかじめ計算しておいたセルの高さ
            size: CGSize(width: UIScreen.main.bounds.width-margin*2,
                         height: viewModel.totalCellHeight-margin*2)
        )
    }
    
    func calculateHeight() -> CGFloat {
        return calculateTextHeight() + margin*2
    }

}

// MARK: - TextCalculatable
extension TextTableViewCell: TextCalculatable {
    
    func calculateTextHeight() -> CGFloat {
        return textView.sizeThatFits(
            CGSize(width: uiscreen.main.bounds.width-margin*2,
                   height: .greatestFiniteMagnitude)
            ).height
    }
}
```

----

# あとがき
まだすべてのセルを書き直したわけではないが、以前からあったカクツキが減少した。

- **Xibの読み込み**
- **AutoLayout**

はコストが高いと実感。

----

## 余談
[**AKIBA.swift × エウレカ コードレイアウト勉強会**](https://classmethod.connpass.com/event/81032/)で教えてもらったのですが、
AutoLayoutの制約がバッティングした際に、Xcodeに出るエラーをわかりやすくしてくれるツールがあります。

[Why The Failure, Auto Layout?](http://www.wtfautolayout.com/)

こんな感じで問題のある**Constraints**を入れると
<img width="1012" alt="スクリーンショット 2018-04-09 2.05.41.png" src="https://qiita-image-store.s3.amazonaws.com/0/175242/882a4c3a-42a9-5056-ce52-f8229bc634da.png">

以下のように表示し直してくれます。
<img width="991" alt="スクリーンショット 2018-04-09 2.05.48.png" src="https://qiita-image-store.s3.amazonaws.com/0/175242/ea14bab0-00a5-d4d1-2cfe-14c001d57967.png">

制約（Constraints）は多いほど重くなるので、バッティングしている箇所があれば直すことをオススメします！

良いXcodeライフを！

# その他
[Storyboard 抜きで、コードオンリーで iOS アプリの UI を作る](https://qiita.com/lovee/items/5e8c7752d7e383660543)