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

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

この記事は Eureka Advent Calendar 2016 14日目 の記事です。
13日目は 香取さん の「今日から始めるDeep Learning」でした。


こんにちは。Couples事業部でiOSアプリの開発を担当している丹です!


今回はFacebookとPinterestがオープンソースとして公開しているAsyncDisplayKitをCouplesで使ってみたので、導入方法を紹介したいと思います。


AsyncDisplayKitはViewのレイアウトを非同期に扱うことで、60FPSのスムーズなUIを実現するためのライブラリです。先日(2016年12月9日)、バージョン2.0になり、安定してきたようなので本格的に導入しても良いと思っています。Swiftのサンプルコードはまだ少ないので、参考になると嬉しいです。


facebook/AsyncDisplayKit


環境

  • AsyncDisplayKit 2.0
  • Swift 2.3
  • Xcode 8.1(8B62)

AsyncDisplayKitの特徴

AsyncDisplayKitではNodeと呼ばれるViewを抽象化したオブジェクトを扱います。ベースのクラスはASDisplayNodeです。UIViewはメインスレッドでしか動作しませんが、ASDisplayNodeはスレッドセーフでバックグラウンドスレッドでも動作します。そのおかげでメインスレッドをブロックしないスムーズなUIを実現することができます。

NodeとNode Container

NodeはNode Containerの中で扱う必要があります。NodeやNode ContainerはUIKitとの対応関係を知ると分かりやすいと思うので、以下に一覧を載せておきます。


Node — AsyncDisplayKit | Node Subclasses

  • ASDisplayNode / UIView
  • ASCellNode / UITableViewCell & UICollectionViewCell
  • ASScrollNode / UIScrollView
  • ASEditableTextNode / UITextView
  • ASTextNode / UILabel
  • ASImageNode & ASNetworkImageNode & ASMultiplexImageNode / UIImage
  • ASVideoNode / AVPlayerLayer
  • ASVideoPlayerNode / UIMoviePlayer
  • ASControlNode / UIControl
  • ASButtonNode / UIButton
  • ASMapNode / MKMapView

Node Container — AsyncDisplayKit | Node Containers

  • ASViewController / UIViewController
  • ASNavigationController / UINavigationController
  • ASTabBarController / UITabBarController
  • ASPagerNode / UIPageViewController
  • ASCollectionNode / UICollectionView
  • ASTableNode / UITableView

AsyncDisplayKitのレイアウト方法

AsyncDisplayKitはStoryboardやInterface Builderを使用せず、レイアウトをすべてコードで書く必要があります。ドキュメントを全部読んだ感想としては、AsyncDisplayKitの導入はレイアウトの組み方をマスターできるかにかかっています。コードはあとで紹介するので、ここでは概要だけ説明します。


AsyncDisplayKitではASLayoutSpecというオブジェクトを使って、レイアウトを組みます。ASLayoutSpecを使ったレイアウトの計算は、以下の2点の理由からAutoLayoutよりも断然速くなります。

  • マニュアルレイアウトと同等の速度(複雑なレイアウトではAutoLayoutは遅くなります)
  • バックグラウンドかつ並列の計算

ASLayoutSpecASLayoutElementプロトコルを採用しているASLayoutSpecASDisplayNodeを扱うことができます。つまり、以下のような入れ子構造が可能になります。

ASLayoutSpec
|- ASLayoutSpec
|- ASDisplayNode

ASLayoutSpecには以下のサブクラスが用意されています。詳しくは AsyncDisplayKit | Layout Specsをご覧ください。

  • ASInsetLayoutSpec
  • ASOverlayLayoutSpec
  • ASBackgroundLayoutSpec
  • ASCenterLayoutSpec
  • ASRatioLayoutSpec
  • ASStackLayoutSpec
  • ASAbsoluteLayoutSpec

Couplesのお知らせ画面をAsyncDisplayKitに置き換える

百聞は一見に如かずということで、実際のコードを見ていきます。今回適用する画面はこちらのお知らせ画面です。シンプルなTableViewです。

Couplesのお知らせ画面

ViewControllerを書く

ASViewControllerUIViewControllerASTableNodeUITableViewは似たインターフェースを持っています。まずは、ViewControllerのイニシャライザとライフサイクルのコードを書きます。

// ASNotificationViewController.swift
import UIKit
import AsyncDisplayKit
// ASViewControllerのサブクラスにします。
final class ASNotificationViewController: ASViewController {
// クラス内で扱いやすくするため、nodeをASTableNodeにキャストします。
var tableNode: ASTableNode {
return node as! ASTableNode
}
// ASTableNodeでnodeを初期化します。
// init内ではself.view, self.node.viewにアクセスしてはいけません。
init() {
super.init(node: ASTableNode())
tableNode.dataSource = self // ASTableDataSource
tableNode.delegate = self // ASTableDataSource
}
override func viewDidLoad() {
super.viewDidLoad()
// メインスレッドなので、ここでViewのセットアップをしましょう。
// tableNode.viewでASTableView(UITableViewのサブクラス)にアクセスできます。
tableNode.view.tableFooterView = UIView()
tableNode.view.backgroundColor = ...
}
DataSourceを書く
ASTableDataSourceUITableViewDataSourceと似たインターフェースを持っています。ASCellNodeBlockを返すメソッドは注意事項がたくさんあるので、気をつけてください。
// ASNotificationViewController.swift
extension ASNotificationViewController: ASTableDataSource {
func numberOfSections(in tableNode: ASTableNode) -> Int {
return 1
}
func tableNode(tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int {
// CoreDataでフェッチ済みのオブジェクト数を返します。
return notifications.numberOfObjects()
}
// tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath)に当たります。
// ちなみに、ASCellNodeは再利用されません。
func tableNode(tableNode: ASTableNode, nodeBlockForRowAtIndexPath indexPath: NSIndexPath) -> ASCellNodeBlock {
// CoreDataのNotificationオブジェクトを取得します。
let notification: Notification = ...
// nodeに渡す際にスレッドセーフなオブジェクトに変換してあげる必要があります。NotificationViewModelはstructです。
let viewModel = NotificationViewModel(notification)
// typealias ASCellNodeBlock = () -> ASCellNode
// このブロックはバックグラウンドスレッドで実行されます。
// ブロック実行時にindexPathが無効になっている可能性があるので、ブロック内でindexPathにアクセスすべきではありません。
let block: ASCellNodeBlock = { ASNotificationCellNode(viewModel: viewModel) }
return block
}
}
上記のコードに出てきたViewModelのインターフェースです。
struct NotificationViewModel {
let body: String
let date: String
let imageURL: String
let isRead: Bool
}
ASNotificationCellNodeを書く
お知らせのセルは、アイコン画像、お知らせの本文、時刻、未読の丸いマークの4つを含んでいます。


既読時
Couplesお知らせ画面のセル既読時
未読時
Couplesお知らせ画面のセル未読時
// ASNotificationCellNode.swift
import UIKit
import AsyncDisplayKit
final class ASNotificationCellNode: ASCellNode {
let viewModel: NotificationViewModel
private let iconNode = ASNetworkImageNode()
private let messageNode = ASTextNode()
private let dateNode = ASTextNode()
private let unreadNode = ASImageNode()
// ASCellNodeBlock内で呼ばれるため、initはバックグラウンドスレッドで動作します。
init(viewModel: NotificationViewModel) {
self.viewModel = viewModel
super.init()
// trueにするとnodeの追加などを自動でやってくれます。
automaticallyManagesSubnodes = true
// iconNode: アイコン画像
iconNode.URL = NSURL(string: viewModel.imageURL)!
iconNode.layerBacked = true // タッチをハンドリングしないnodeはtrueにすることでパフォーマンスが向上します。
// 画像自体を丸くする処理を書きます。このブロックはバックグラウンドスレッドで実行されます。
iconNode.imageModificationBlock = { image in
let modifiedImage: UIImage
let rect = CGRect(origin: .zero, size: image.size)
UIGraphicsBeginImageContextWithOptions(image.size, false, 0)
UIBezierPath(roundedRect: rect, cornerRadius: 25 * UIScreen.mainScreen().scale).addClip()
image.drawInRect(rect)
modifiedImage = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
return modifiedImage
}
// bodyNode: お知らせの本文
bodyNode.attributedText = NSAttributedString.CouplesAttributedString(viewModel.body, color: UIColor.CouplesColor000, fontSize: 14, bold: viewModel.isRead)
bodyNode.layerBacked = true
bodyNode.maximumNumberOfLines = 0
// dateNode: 時刻
dateNode.attributedText = NSAttributedString.CouplesAttributedString(viewModel.date, color: UIColor.CouplesColor005, fontSize: 12, bold: false)
dateNode.layerBacked = true
dateNode.maximumNumberOfLines = 1
// unreadNode: 未読の丸いマーク
unreadNode.layerBacked = true
}
}
UIKitオブジェクトの設定をする場合は、didLoadメソッド内で行いましょう。メインスレッドで呼ばれます。
// ASNotificationCellNode.swift
extension ASNotificationCellNode {
override func didLoad() {
super.didLoad()
backgroundColor = viewModel.isRead ? UIColor.whiteColor() : UIColor.CouplesColorBackground
// 丸い画像を作るextensionもAsyncDisplayKitには用意されています。
unreadNode.image = UIImage.as_resizableRoundedImageWithCornerRadius(5, cornerColor: UIColor.clearColor(), fillColor: UIColor.CouplesColor200)
}
}
ASNotificationCellNodeのレイアウトを書く
セルのレイアウトを組んでいきます。画像内の番号とコードの番号は対応しています。コードが分割されていますが、すべてlayoutSpecThatFitsメソッドの中身になります。
// ASNotificationCellNode.swift
extension ASNotificationCellNode {
// このメソッド内でレイアウトを決定します。
// バックグラウンドスレッドで呼ばれることに気をつけてください。
override func layoutSpecThatFits(constrainedSize: ASSizeRange) -> ASLayoutSpec {
// 1. 画像のサイズを指定します。
iconNode.style.preferredSize = CGSize(width: 50, height: 50)
// 2. テキストをVerticalに並べます。
let textLayout = ASStackLayoutSpec(
direction: .Vertical,
spacing: 4, // 画像の緑のマージンになります。
justifyContent: .SpaceBetween, // bodyNodeの上端と、dateNodeの下端がtextLayoutの上下端になります。
alignItems: .Start, // bodyNodeとdateNodeの左端を揃えます。
children: [bodyNode, dateNode]
)
// 縮んだり、伸びたりすることを防ぎ、TextNodeの文字がちょうど収まるようにレイアウトします。
textLayout.style.flexShrink = 1.0
textLayout.style.flexGrow = 1.0
// 3. Horizontalに並べます。
// このあと、HorizontalなStackLayoutSpecで囲んであげるので、
// textLayoutの左右のマージンになります。図の緑のマージンです。
textLayout.style.spacingBefore = 10.0 // 左のマージン
textLayout.style.spacingAfter = 16.0 // 右のマージン
// 既読と未読でレイアウトするnodeを分けます。
let horizontalNodes: [ASLayoutElement]
if viewModel.isRead {
horizontalNodes = [iconNode, textLayout]
}
else {
unreadNode.style.preferredSize = CGSize(width: 10, height: 10)
horizontalNodes = [iconNode, textLayout, unreadNode]
}
let horizontalStack = ASStackLayoutSpec(
direction: .Horizontal,
spacing: 0, // textLayout.style.spacingBefore等でマージンは指定してあるので、spacingは0です。
justifyContent: .SpaceBetween,
alignItems: .Center, // Vertical方向にセンタリングします。
children: horizontalNodes
)
// 4. 上下左右のinsetを指定します。
// layoutSpecThatFitsのメソッドではASLayoutSpecを返します。
return ASInsetLayoutSpec(insets: UIEdgeInsets(top: 15, left: 12, bottom: 15, right: 20), child: horizontalStack)
}
// layoutSpecThatFitsの終わり
}
ASTableDelegateでフェッチのロジックを書く
これが最後のパートになります。ASTableNodeはデフォルトで画面サイズの2画面分先までをプリフェッチするようになっています。その際に呼ばれるメソッドが2つあります。
// ASNotificationViewController.swift
extension ASNotificationViewController: ASTableDelegate {
// フェッチをするかどうかです。
// プリフェッチをする領域までスクロールした場合に、
// バックグラウンドスレッドで呼ばれます。
func shouldBatchFetchForTableNode(tableNode: ASTableNode) -> Bool {
return true
}
// フェッチの実行部分です。
// バックグラウンドスレッドで呼ばれます。
func tableNode(tableNode: ASTableNode, willBeginBatchFetchWithContext context: ASBatchContext) {
// Notificationをフェッチするコードをここに書きます。
fetchNotifications(completion: { () -> Void in
let insertedIndexPathes: [NSIndexPath]() = ...
// IndexPathの配列を渡して、Insertをします。
tableNode.insertRowsAtIndexPaths(insertedIndexPathes, withRowAnimation: .Fade)
// 最後にフェッチが完了したことを伝えます。trueは成功したことを意味します。
context.completeBatchFetching(true)
}
}
}
以上になります!
最後に
AsyncDisplayKitを使用することでフレームレートを向上させることができました。


また、レイアウトやプリフェッチのコードを簡単に書けることが分かりました。
AsyncDisplayKitの存在は知っているけど、手が出せていない方はぜひ挑戦してみてください。


ただし、AsyncDisplayKitで実現できないUIも存在するかもしれません。そのため、アプリ全体で採用していくのはリスクだと思います。


明日の記事は恩田さんの「Terraformを約1年運用して学んだトラブルパターン4選」になります!
Like what you read? Give eureka_developers a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.