Objective-C
iOS
UICollectionView
Swift
Realm

IGListKitでフィードUIをリファクタする

More than 1 year has passed since last update.

ミクシィグループ Advent Calender 2016、17日目です。

家族アルバム みてね」という家族コミュニケーションアプリの開発チームリーダーを担当しております。

2015年ごろから社内のiOSエンジニアがランチタイムを利用して集まり、さまざまなカンファレンスなどのプレゼン動画を見る勉強会を週1回行っています。2016年もたくさんの動画を見たので、その中からあまり国内で紹介されていない内容で、印象に残ったものを紹介します。


IGListKitの紹介

IGListKitというライブラリを紹介します。

https://github.com/instagram/IGListKit

IGListKitはInstagram製のUICollectionViewのframeworkです。

frameworkに準拠したコードを書くと、表示したいデータ構造をもとにUICollectionViewがList系の表示を組み上げてくれるようになっています。

特徴としては以下のようなポイントが挙げられます。


  • 様々な型のデータが含まれるCollectionも単純なコードでリスト表示できる

  • Collectionの差分を気にしながらperformBatchUpdates(_:completion:)reloadData()を繊細に叩きわけることをしなくてもよい

  • Swift3でも問題なくプログラム可能

少し前にオープンソース化され、つい先日v2.0.0がリリースされたようです。


紹介動画

こちらのRealmによるTry!Swiftの動画で知ることができました。

https://realm.io/news/tryswift-ryan-nystrom-refactoring-at-scale-lessons-learned-rewriting-instagram-feed/


どういう場面で効果的?


フィード・タイムライン系UIに最適

Instagramでは、トップのフィードをはじめ、アプリ内の各所ですでに導入されているようです。

動画のなかでも話が出ていますが、ユーザーが頻繁に目にする「フィード」や「タイムライン」のような表示は、様々な型のオブジェクトを組み替えたり・足したり・引いたりなど、ビジネスの要求によって機能要件が柔軟に変更される可能性があり、またエンジニアとしてはそれらに答えていく必要があります。

「家族アルバム みてね」においても、以下のような「近況」という機能で家族の各アクティビティや「みたよ履歴」という家族のログイン履歴がひとつのフィード画面に表示される場面があります。

IMG_1731.PNG

こういう画面の構築においてIGListKitが効果を発揮します。


サンプル実装

みてねの「近況」に似た画面をIGListKitで開発したい思います。


Feedの表示


Model

まず、フィードの各オブジェクトを表現するFeedというModelを作ります。

https://github.com/radioboo/ListKitDemo/blob/master/ListKitDemo/Models/Feed.swift

class Feed {

let id: String
let user: User
let comment: String
let image: UIImage

init(id: String, user: User, comment: String, image: UIImage) {
self.id = id
self.user = user
self.comment = comment
self.image = image
}
}

IGListKitで扱うオブジェクトはIGListDiffableに準拠する必要があります。

import IGListKit

extension Feed: IGListDiffable {
// Entityをユニークに示すIdentifierとなるプロパティ
func diffIdentifier() -> NSObjectProtocol {
return id as NSObjectProtocol
}

// Entity同士が同一であることを示すロジックを実装
func isEqual(toDiffableObject object: IGListDiffable?) -> Bool {
if let feed = object as? Feed {
return id == feed.id
}
return false
}
}

上記のように実装したFeedモデルのインスタンスのidが同じ場合は、同一オブジェクトとみなされ、Feed型を含むCollectionのupdate時にUIの更新がスキップされることになります。この挙動はIGListDiffという差分アルゴリズムの実装により実現されています。


IGListDiff

IGListDiffは、以下のブログにも記述されている通り、1978年のPaul Heckelの論文を基に実装されたものとのことです。非常に高速なので、IGListKitはこれ単体の利用目的でも十分価値があるものかもしれません。

(高速、といいつつ、自分自身はまだ大規模なデータ構造でパフォーマンス検証してはいないです…)

https://engineering.instagram.com/open-sourcing-iglistkit-3d66f1e4e9aa#.6lls1kmdh

Headerを見ると、差分の有無から、inserts/deletes/updates/movesなどにアクセスするindexを結果として取得できるようです。

https://github.com/Instagram/IGListKit/blob/master/Source/IGListIndexSetResult.h


SectionController

SectionControllerとは、Adapterよりアサインされる単一のFeedオブジェクトを再利用されるView(UICollectionViewCell)に配置していくための小さなControllerの実装となります。CellはXIBで記述することも可能で、それを再利用可能な形で初期化するためのインターフェースもあります。

https://github.com/radioboo/ListKitDemo/blob/master/ListKitDemo/SectionControllers/FeedSectionController.swift

final class FeedSectionController: IGListSectionController, IGListSectionType {

var feed: Feed?

func numberOfItems() -> Int {
return 1
}

func sizeForItem(at index: Int) -> CGSize {
return CGSize(width: collectionContext!.containerSize.width, height: 100)
}

func cellForItem(at index: Int) -> UICollectionViewCell {
guard let feed = feed else {
fatalError("feed is nil.")
}
let cell = collectionContext?.dequeueReusableCell(withNibName: "FeedCell", bundle: nil, for: self, at: index) as! FeedCell
cell.commentLabel?.text = "\(feed.user.nickname)さんが「\(feed.comment)」とコメントしました。"
cell.imageView?.image = feed.image
return cell
}

func didUpdate(to object: Any) {
self.feed = object as? Feed
}

func didSelectItem(at index: Int) {}
}


ViewController

ここまで実装したら、次にViewController側でUICollectionViewを初期化し、IGListAdapter向けに表示したいデータを提供するためのDataSource実装を行います。

https://github.com/radioboo/ListKitDemo/blob/master/ListKitDemo/Scenes/ViewController.swift

// AdapterとUICollectionViewの初期化

lazy var adapter: IGListAdapter = {
return IGListAdapter(updater: IGListAdapterUpdater(), viewController: self, workingRangeSize: 0)
}()

let collectionView = IGListCollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())

// IGListAdapterDataSourceの実装
extension ViewController: IGListAdapterDataSource {

func objects(for listAdapter: IGListAdapter) -> [IGListDiffable] {
return [
Feed(id: UUID().uuidString, user: allUsers()[0], comment: "はい…", image: UIImage(named: "IMG_005.jpg")!),
Feed(id: UUID().uuidString, user: allUsers()[4], comment: "おっさんそば食えや", image: UIImage(named: "IMG_005.jpg")!),
...
] as! [IGListDiffable]
}

func listAdapter(_ listAdapter: IGListAdapter, sectionControllerFor object: Any) -> IGListSectionController {
return FeedSectionController()
}

func emptyView(for listAdapter: IGListAdapter) -> UIView? {
return nil
}
}


表示確認

ここまでの実装で以下のようにFeedオブジェクトがズラッと表示されるようになります。

Simulator Screen Shot 2016.12.19 12.44.03.png


みたよ履歴をフィードに追加

さらに、「みたよ履歴」の部分を画面上部に追加してみましょう。

基本的には上記までのステップと同様、必要なクラスやプロトコルの実装を追加していき、DataSourceに「みたよ履歴」用のデータ構造を突っ込むとあっさり表示されるようになるのですが、ここでは、フィードのCellの中にさらにUICollectionViewをネストさせた形で実装してみます。


Modelを2種類追加

履歴全体を管理するUserHistoryオブジェクトを追加し、その中にログイン順にUserオブジェクトを持つようなModelとして設計します。これらは両方とも IGListDiffableに準拠しています。コードはリンク先で確認してください。



  • User


    • 履歴に表示するユーザーのオブジェクト




  • UserHistory


    • Userのリストを抱えた履歴全体を表現するオブジェクト




SectionControllerを2種類追加

UICollectionViewをネストさせる場合、ネストさせるUICollectionViewを表示するためのCellを描画するSectionControllerと、ネストされたUICollectionView内部でさらに描画を行うSectionControllerを2種類追加する必要があります。


UserHistorySectionController

https://github.com/radioboo/ListKitDemo/blob/master/ListKitDemo/SectionControllers/UserHistorySectionController.swift

このSectionControllerでは、以下のようにCell(EmbeddedCollectionViewCell)を返していますが、このCellの実態は、Cell内部にUICollectionViewをさらに持ったものとなります。

func cellForItem(at index: Int) -> UICollectionViewCell {

let cell = collectionContext!.dequeueReusableCell(of: EmbeddedCollectionViewCell.self, for: self, at: index) as! EmbeddedCollectionViewCell
adapter.collectionView = cell.collectionView
return cell
}

さらに、このSectionControllerはIGListAdapterDataSourceに準拠しているため、別のSectionContrllerを返す必要があります。ここで返されるSectionControllerが実際にUserオブジェクトを並べるCellを描画するものとなります。

extension UserHistorySectionController: IGListAdapterDataSource {

func objects(for listAdapter: IGListAdapter) -> [IGListDiffable] {
return (userHistory?.users)!
}

func listAdapter(_ listAdapter: IGListAdapter, sectionControllerFor object: Any) -> IGListSectionController {
return EmbeddedSectionController()
}

func emptyView(for listAdapter: IGListAdapter) -> UIView? {
return nil
}
}


EmbeddedSectionController

https://github.com/radioboo/ListKitDemo/blob/master/ListKitDemo/SectionControllers/EmbeddedSectionController.swift

このSectionControllerは、履歴に含まれるUserオブジェクトをUICollectionViewCellに描画するシンプルなクラスになります。


ViewControllerの修正

ViewControllerにおいては、表示するデータ構造の先頭に「みたよ履歴」用のデータ構造を突っ込んでおきます。

そして、DataSourceにおいて型ごとに返すSectionControllerを変えるように実装をすると、型に応じたCellの描画が行われます。

func objects(for listAdapter: IGListAdapter) -> [IGListDiffable] {

return [
UserHistory(users: [
User(id: UUID().uuidString, nickname: "radioboo"),
User(id: UUID().uuidString, nickname: "ainame"),
...
]),
Feed(id: UUID().uuidString, user: allUsers()[0], comment: "はい…", image: UIImage(named: "IMG_005.jpg")!),
Feed(id: UUID().uuidString, user: allUsers()[4], comment: "おっさんそば食えや", image: UIImage(named: "IMG_005.jpg")!),
...
] as! [IGListDiffable]
}

func listAdapter(_ listAdapter: IGListAdapter, sectionControllerFor object: Any) -> IGListSectionController {
if object is UserHistory {
return UserHistorySectionController()
} else {
return FeedSectionController()
}
}


表示確認

ここまでの実装で以下のように画面上部に「みたよ履歴」が追加されるようになりました。

Simulator Screen Shot 2016.12.19 12.44.20.png


要件の変更でさらに別のオブジェクトを追加

とあるタイミングで突如、プロダクトオーナーからこういう要求があったとしましょう。

あたらしい機能をもっと使ってもらいたいから、近況フィードの3行目に必ず誘導するためのフィードを追加して欲しい

既存のコードでコードの変更を混乱なく上記の要件を実現することが気楽にできるでしょうか?

難しくはないかもしれませんが、// NOTE: 歴史的経緯みたいなコメントが多発する業の深いコードが生み出される危険性があります。

IGListKitを利用していれば、


  • IGListDiffableに準拠したModelを実装

  • 表示されるView(UICollectionViewCell)を実装

  • SectionControllerを実装

  • IGListAdapterDataSourceの実装で表示したい型のSectionControllerを返す

  • 既存のデータ構造にい変更を加える

という手順を踏めば混乱のない、見通しの良いコードが書けるようになりました。

実装差分は、以下のPull Requestを参照してください。

https://github.com/radioboo/ListKitDemo/pull/3/files

Simulator Screen Shot 2016.12.19 12.44.47.png


サンプル

実装したサンプルのコードの全てはGitHubにpushしてあるので、興味のある方は参照してください。

https://github.com/radioboo/ListKitDemo


まとめ


  • ユーザーが高頻度に利用するフィードUIの実装は、複雑な要件が重りがちな上に、その要件自体もビジネスの状況に応じて常に柔軟に変わっていく必要がある

  • スマホアプリではフィード系の表示は無限に実装しなくてはいけない

  • しかし、下手に実装すると常にリファクタとの戦いとなる

IGListKitを用いて、リファクタしよう!!!!!!!!