滑らかな UI とは
滑らかな UI とはどんなものか。UI の滑らかさを語る上で欠かせないのがフレームレートという指標である。1 秒間に表示されるフレームの数を FPS(Frame Per Second)という。この値が高いほど人間の目には滑らかに表示されているように見える。
iOS デバイスの最大 FPS は UIScreen.main.maximumFramesPerSecond を使って取得することができる。2018 年時点で販売されている iPhone 端末で上記のコードから得られる値は 60 であるため、常に 60FPS で UI を描画することが iOS アプリケーションで「滑らかな UI」を実現しようとする際の一つの目標となる。
日経電子版 iOS アプリの高速化
図表に日経電子版 iOS アプリの UI をいくつか掲載する。日経電子版 iOS アプリではどの画面にもほぼ例外なく UITableView が利用されている。したがって、日経電子版アプリの UI 高速化はほぼ UITableView の UI 高速化であると言い換えることができる。次の章からは UITableView の高速化のために行った様々な施策を紹介する。


UITableView の高速化
UITableView は iOS SDK が標準で提供する UI コンポーネントであり、非常に多くのアプリケーションで利用されている。
一方でパフォーマンスの差がはっきりと目につきやすいコンポーネントであることも知られており、その高速化を成し遂げるためのノウハウは Stack Overflow などに古くから蓄積されている。日経電子版 iOS アプリの開発においてもそれらの基本的なノウハウを適切に運用することから高速化の施策が始まった。
これらの取り組みに関しては勉強会の資料が公開 (https://speakerdeck.com/ikait/the-way-to-60-fps) されているため、本稿では概要のみを解説する。
基本のノウハウ 1: UI スレッドをブロックしない
- API 通信、ローカル DB やディスクキャッシュへの書き込みなどをバックグラウンドで実行する。
- バックグラウンドで構築できるコンポーネントはバックグラウンドで構築する。
基本のノウハウ 2: 事前に計算する
-
クラアント側で計算する必要がない値は可能な限りサーバーサイドで計算して返す。
例えばタイムスタンプなどは UnixTime 形式に変換してからクライアントに渡すことで余計な処理時間を省くことができる。
-
UITableViewCell の高さ計算を事前に実行する。
UITableViewCell の高さ計算を OS に任せることは可能である。簡単なアプリケーションを作成している場合はこの方式を採用する場合も多いであろう。しかし、Cell の高さのバリエーションが少ない場合はモデルデータを取得した段階で事前に計算しておいた方がパフォーマンス上有利になる場合が多い。
日経電子版 iOS アプリではモデルデータを取得した際に非同期で Cell の高さ計算を実行し、その値を返す方式を採用している。
基本のノウハウ 3: キャッシュする
-
UITableViewCell など、再利用される View コンポーネントはキャッシュの機構を利用することでパフォーマンスを向上させられることが知られている。
それ以外にも UIColor、DateFormatter などはアプリケーション全体で再利用が可能である上、初期化コストが大きい。こういったオブジェクトは事前に初期化してキャッシュしておくことでパフォーマンスを低下させないようにしている。
これらの施策によって高速化を実現した一方で日経電子版 iOS アプリ特有の事情から問題が残る部分もいまだに存在する。
日経電子版 iOS アプリではメインとなる記事一覧タブに合計 15 個の UITableView が並列に並んでいる。一つ一つの UITableView では問題がないものの、Pull to Refresh で全ての UITableView を一斉に再描画するようなタイミングでは僅かながら UI のロックが発生してしまうのである。
この問題を回避するために日経電子版 iOS アプリでは Texture (AsyncDisplayKit) の導入を進めている。
Texture の導入
Texture (http://texturegroup.org) は複雑なインターフェースを滑らかに、レスポンシブに描画することを目的としたフレームワークである。UIKit ライクな API を提供する Node オブジェクトを利用して UI を構築することでメインスレッドをブロックせずに UI を描画できることが特徴である。Texture は Facebook Paper のために開発され、最近では Pinterest の再構築 (https://www.wired.com/2016/04/pinterest-reinvents-prove-really-worth-billions/) に利用された経歴がある。
Texture の仕組み
-
スマートプリロード
UITableView には画面上に表示する前段階から UITableViewCell の初期化を始めるために tableView( tableView:willDisplay cell:forRowAt indexPath:)_ や tableView(:prefetchRowsAt:)_ といったデリゲートメソッドが用意されている。Texture ではこれらのデリゲートメソッドを踏襲するとともに、より細やかな管理が可能となっている。
-
Core*クラスを利用したバックグラウンドでの UI 構築
UI を構築する際に UIKit から提供されるクラスではなく CoreText , CoreAnimation などのクラスを利用している。これによってメインスレッドの制約を逃れ、バックグラウンドで並列に UI コンポーネントを構築できるようにしている。
-
Autolayout の撤廃
UI のレイアウトをバックグラウンで行うために Autolayout を利用せず CSS の Flexbox ライクな API を提供する LayoutSpec クラスを採用している。これによってバックグラウンドでレイアウト処理を実行できるようにしている。
Texture を利用して既存のアプリケーションを置き換える
現在、UI 高速化の取り組みのひとつとして日経電子版 iOS アプリでは Texture による既存アプリケーションの置き換えを実施している。置き換えのフローと注意点は以下のようなものである。書き換え前後のサンプルコードを合わせて掲載するので見比べながら確認してほしい。
ViewController.swift (書き換え前)
import UIKit
class ViewController: UIViewController {
private let cellIdentifier = "cell"
private let cellText: String
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: self.cellIdentifier)
tableView.delegate = self
tableView.dataSource = self
return tableView
}()
init(viewModel: ViewModel) {
self.cellText = viewModel.cellText
super.init(nibName: nil, bundle: nil)
self.view.addSubview(tableView)
// MARK: - layout
tableView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
tableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
tableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
tableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
}
}
extension ViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 44
}
}
extension ViewController: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int { return 1 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 1 }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if let cell = tableView.dequeueReusableCell(withIdentifier: self.cellIdentifier) {
cell.textLabel?.text = "cell"
return cell
}
return UITableViewCell()
}
}
AsyncViewController.swift (置き換え後)
import UIKit
// TextureはもともとAsyncDisplayKitという名前で開発されていた。
// Carthageで参照する際の名前はtextureに変更されているものの、
// 互換性保持のためにimportで参照するライブラリ名はAsyncDisplayKitのままになっている。
import AsyncDisplayKit
// ASViewControllerはジェネリックスパラメーターとして基底のNodeを表現するクラスを渡す。
// ASDisplayNodeを渡せばUIViewControllerを、ASTableNodeを渡せばUITableViewControllerを表現することができる。
class AsyncViewController: ASViewController<ASDisplayNode> {
private let cellText: String
lazy var tableNode: ASTableNode = {
let tableNode = ASTableNode()
tableNode.delegate = self
tableNode.dataSource = self
return tableNode
}()
init(viewModel: ViewModel) {
self.cellText = viewModel.cellText
let node = ASDisplayNode()
node.automaticallyManagesSubnodes = true
super.init(node: node)
// MARK: - layout
// レイアウト処理はConstraintsではなくLayoutSpecを利用して行われる
node.layoutSpecBlock = { node, constrainedSize in
// LayoutSpecはCSS FlexboxライクなAPIをもつレイアウトフレームワークである
// 親のNode全体に被さるように配置する場合は ___ASWrapperLayoutSpec___ を利用すればよいなど、
// 一般的に必要となるようなレイアウトコンポーネントが提供されている
return ASWrapperLayoutSpec(layoutElement: self.tableNode)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
}
}
extension AsyncViewController: ASTableDelegate {
// それぞれのCellが個別のスレッドでレイアウト処理を実行するため、ASTableNode自身でcellの高さを計算することはない。
// Cellの高さ計算はCellクラスのlayoutSpecに完全に委ねられる。
}
extension AsyncViewController: ASTableDataSource {
func numberOfSections(in tableNode: ASTableNode) -> Int { return 1 }
func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int { return 1 }
func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
// 非同期レイアウトの恩恵を受けるため、基本的にNodeBlockを返すことができる場面ではNodeBlockを返す。
return {
// このブロックはバックグラウンドで実行される。
// また、TextureではCellの再利用を行わない。indexPathから返すべきCellを特定しインスタンスを生成して返却する必要がある。
let node = ASCellNode()
// Cellの選択はASTableDelegateで制御できるため、
// Cell上のオブジェクトのイベントを無視してパフォーマンスを向上させる。
node.isLayerBacked = true
node.enableSubtreeRasterization = true
return node
}
}
// Cellのインスタンスを直接返すdelegateも用意されているが、メインスレッドで実行されるため可能な限り利用するべきではない。
// func tableNode(_ tableNode: ASTableNode, nodeForRowAt indexPath: IndexPath) -> ASCellNode {
// let node = ASCellNode()
// return node
// }
}
1. ViewController を ASViewController に置換する
ViewController を継承しているクラスを ASViewController を継承するように書き直す。ASViewController は ViewController の継承クラスであり、API 互換があるため、特別な書き換えをせずに動作する。
ASViewController に渡すジェネリクスは書き換えもとのクラスに応じて適切な Node クラスを指定する。具体的には UIViewController であれば ASDisplayNode, UITableViewController であれば ASTableNode などである。
2. UIView を ASDisplayNode に置換する
UIView を継承しているクラスを ASDisplayNode を継承するように書き直す。ASDisplayNode も UIView と API 互換がある。注意するべきポイントは以下の通りである。
init() がバックグランドで実行される。この処理の最中に UIKit が提供するクラスにアクセスするとクラッシュする可能性がある ASDisplayNode から提供される API はスレッドセーフであるため、そちらを利用するように書き換える。どうしても難しい場合は didLoad() 内に処理を移せばメインスレッドで実行される。
addSubview(:)_ など一部の API が利用できない。UI コンポーネントの階層を制御する API は addSubNode(:)_ など、Node という名称を用いたものになっているのでそちらを利用するように書き換える。ASDisplayNode に互換性のある API が存在しない場合は node.view から規定の UI クラスにアクセスすることができる。
Autolayout が利用できない。バックグラウンドでレイアウト処理を実行する都合上、Autolayout が利用できなくなっている。Texture では LayoutSpec と呼ばれる同時のレイアウトコンポーネントを利用することになる。詳細は後の項目で解説する。
3. UITableVIew, UICollectionView などを対応する Node Container に置換する
それぞれ ASTableNode, ASCollectionNode といったクラスが対応しているため置換する。ASDisplayNode と同様に基本的な API 互換がある。一方で Delegate や DataSource は大きく様変わりするため、書き換えが必要になる。主なポイントは以下の通りである。
Cell を返すデリゲートが 2 パターン提供される。tableView( tableView:cellForRowAt indexPath:)_ の代わりに tableNode( tableNode:nodeForRowAt indexPath:)_ および tableNode( tableNode:nodeBlockForRowAt indexPath:)_ が提供される。
Node を返すデリゲートはメインスレッドで、NodeBlock を返すデリゲートは NodeBlock 内がバックグラウンドで実行されることになる。こういったパターンの API 置き換えが Texture には頻繁にある。可能な限り NodeBlock を返す API を利用することで高速化を進めることができる。
Cell の再利用がない。UITableView で利用していた dequeueReusableCell(withIdentifier identifier:) がない。Texture では Cell の再利用をしないため、単純に処理を取り除くだけで良い。
Cell の高さ計算がない。UITableView で利用していた tableView( tableView:heightForRowAt indexPath:)_ など Cell の高さを計算する API がない。
Texture では Cell の高さは自動で計算されるか、Cell の LayoutSpec で指定することになる。複雑な高さ計算を UITableView の delegate で行なっていた場合は各種の処理をそれぞれの Cell に委譲する必要がある。
4. Cell を ASCellNode に置換する
UITableViewCell を継承しているクラスを ASCellNode を継承するように書き換える。API 互換があるため、基本的に書き換えは必要ない。注意点は ASDisplayNode への書き換えと同様である。
5. レイアウト処理を LayoutSpec に置換する
Texture では Autolayout を利用せず、LayoutSpec と呼ばれる独自のレイアウトクラスを利用して UI の配置を行っていく。LayoutSpec は CSS の Flexbox ライクな API を提供するレイアウトクラスである。Autolayout とは全く異なる思想ではあるものの、レイアウトに必要な基本的なコンポーネントが揃っているためあまり不自由はない。
Texture の公式サイトに Example を含め詳細な解説が掲載されている (http://texturegroup.org/docs/layout2-quickstart.html) のでそちらを確認することを推奨する。
6. ライフサイクルを最適化する
Texture ではメインスレッドで実行される API とバックグランドスレッドで実行される API が同時に提供されるパターンが多い。原則としてバックグラウンドスレッドで実行される API を利用するべきである。戻り値が**nodeBlock** になっているようなわかりやすい API がある一方で、**init()**などシグネチャからではどのスレッドで実行されるのか不明瞭な API も多い。各 API を利用する際には API ドキュメントを一読することをお勧めする。
7. オブジェクトの描画を最適化する
ASDisplayNode で描画しているオブジェクトは条件を満たすことでより描画に最適化することが可能になっている。また、最適化をフラグ 1 つで ON/OFF することが可能であるため、UI を構築した後に少しずつ最適化を進めていくことが容易である。
isLayerBacked フラグを true にすると指定した Node 以下の Node が CALayer ベースで描画されるようになる。パフォーマンスの観点では CALayer ベースの描画が UIView ベースの描画よりも優れているため、ユーザーが操作しない要素についてはこのフラグを true にすることが推奨されている。
enableSubtreeRasterization フラグを true にすると指定した Node の View 階層がフラットになる(図表を参照)。描画のパフォーマンスが向上することに加えて、このフラグを true にしてもユーザーの操作を受け付けることができる。ただし、View 階層がフラットになっているために意図しない View のジェスチャーが発火することがあるため、複数のボタンを設置している場合などは動作の確認が必須である。
shouldRasterizeDescendants を利用することで cornerRadius を指定した際の描画パフォーマンスが向上する。ユーザーアイコンを丸く切り出す際に UIImage の Layer に cornerRadius を指定することで実現する手法はしばしば用いられるが、パフォーマンスの懸念があることでも知られている。
Texture では**shouldRasterizeDescendants** フラグを true にすることでこの描画パフォーマンスを向上させることができる。この手法は副作用があるため、筆者は推奨しない。一般的なプラクティスに従って、サーバサイドであらかじめ丸く切り取った画像を生成するべきだろう。
本稿では日経電子版 iOS アプリにおける UI 高速化の手法と現在の取り組みについて紹介した。日経電子版 iOS アプリはニュースを読むだけのシンプルなアプリケーションである。であるからこそ、現実の書籍や新聞に劣らないようなストレスのない購読体験が最も重要であると筆者は考えている。
日経電子版 iOS アプリではこれからもより快適な購読体験のために UI の高速化や改善をおこなっていく。利用している方は使い勝手についてぜひフィードバックを送っていただきたい。フィードバックは各プラットフォームの「ご意見・ご要望」メニューから送信することができる。