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との対応関係を知ると分かりやすいと思うので、以下に一覧を載せておきます。

NodeAsyncDisplayKit | 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 ContainerAsyncDisplayKit | 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メソッドの中身になります。

Couplesお知らせ画面セルのレイアウト1

// ASNotificationCellNode.swift

extension ASNotificationCellNode {
    // このメソッド内でレイアウトを決定します。
    // バックグラウンドスレッドで呼ばれることに気をつけてください。
    override func layoutSpecThatFits(constrainedSize: ASSizeRange) -> ASLayoutSpec {

        // 1. 画像のサイズを指定します。
        iconNode.style.preferredSize = CGSize(width: 50, height: 50)

Couplesお知らせ画面セルのレイアウト2

        // 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

Couplesお知らせ画面セルのレイアウト3

        // 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
        )

Couplesお知らせ画面セルのレイアウト4

        // 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選」になります!

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

GOPATH を build.Default.GOPATH で適切に扱う

Go+App Engine+Cloud SQLで始めるGo言語Webアプリケーション開発