Slide 1

Slide 1 text

STAGE 1 iOS Engineer SESSION TAG ⼩清⽔ 健⼈ Eight iOS アプリの開発⼿法

Slide 2

Slide 2 text

技術本部 Mobile Applicationグループ iOSエンジニア ⾦融系SI、アプリ受託開発、EC事業会社を経て、 2021年4⽉ Sansan株式会社 中途⼊社。 Eight iOS アプリの開発に従事。 Eight ONAIR、Eight Marketing Solutions 刷新、ミニレポ などを開発。 ⼩清⽔ 健⼈

Slide 3

Slide 3 text

• Eight について • 設計 • アーキテクチャ • Eight iOS の実装例 セッション概要 Table of contents

Slide 4

Slide 4 text

Eight について

Slide 5

Slide 5 text

名刺を管理する機能以外にもさまざまな機能 プロフィール 検索 メッセージ フィード( 投稿 ) 連絡先 / 会社 名刺撮影 ONAIR お知らせ Eight について 名刺でつながる、 ビジネスのためのSNS

Slide 6

Slide 6 text

Eight について Eight iOS アプリの規模感 • Compile Sources:2200 + • Swift 86 % • Objective-C 14 % • Bundle Resources 600 +

Slide 7

Slide 7 text

• Eight では1週間のスプリントでスクラム開発をしています。 • スプリント計画 • 開発( 設計/実装 ) • スプリントレビュー Eight について スクラム開発

Slide 8

Slide 8 text

設計フェーズ

Slide 9

Slide 9 text

• Eight iOS チームでは次のように設計を進めます。 • クラス図 • 設計レビュー • インターフェースの実装 設計フェーズ 設計

Slide 10

Slide 10 text

PlantUML を⽤いてクラス図に落とし込みます。 Eight iOS では『 MVVM + Coordinator 』パターンを 採⽤しています。このパターンに則り、プロトコル、 クラス、構造体などのインターフェースやデータ構造 をすべてクラス図として落とし込みます。 設計フェーズ クラス図

Slide 11

Slide 11 text

設計フェーズ

Slide 12

Slide 12 text

• iOS の開発メンバー全員で対⾯形式(オフライン / オンライン)で実施 • インターフェースに過不⾜がないか • 実装の⽅法の事前の認識合わせ • 開発メンバーからのフィードバックをもとに設計を決定 設計フェーズ 設計レビュー

Slide 13

Slide 13 text

設計を実装します。実際に設計を実装することで、各クラスのインターフェースに問題がない か明らかにします。必要なクラスなどを開発前段階で事前にコミット、develop ブランチに マージすることで、開発メンバーはクラス単位で開発をすすめることができます。 設計フェーズ インターフェースの実装

Slide 14

Slide 14 text

アーキテクチャ

Slide 15

Slide 15 text

アーキテクチャ MVVM + Coordinator パターン Model ViewModel View Coordinator

Slide 16

Slide 16 text

View はデータの表⽰と UI イベントを送信するクラスで す。 UIViewController が View の責務を負います。 ViewModel とデータバインディングすることでデータを 表⽰します。 Eight iOS では UI イベントの受信先のクラスを View か ら隠蔽します。データバインディング機構と UI イベント の発⾏を外部公開する設計としています。 アーキテクチャ View ViewModel View ( UIViewController ) UI Event View item

Slide 17

Slide 17 text

ViewModel は View と Model の仲介者です。 Model にデータの取得の依頼とデータの取得・変更の検 知をします。取得したドメインオブジェクトを 表⽰⽤オ ブジェクトに変換して View へ発⾏します。 また、画⾯遷移のためのイベントを発⾏します。 画⾯遷移イベントは Coordinator が購読して画⾯遷移を 解決します。 アーキテクチャ ViewModel Model ( UseCase ) ViewModel Request Domain object

Slide 18

Slide 18 text

Model は ViewModel 依頼を受けてデータの取得を⾏います。 Eight iOS では、UseCase クラスが ViewModel に対して、 ドメインの値型オブジェクトを提供します。 アーキテクチャ Model Model ( UseCase )

Slide 19

Slide 19 text

アーキテクチャ Eight の Model ViewModel UseCase API Client Realm Manager

Slide 20

Slide 20 text

アーキテクチャ Eight の Model ViewModel UseCase API Client Realm Manager ViewModel がデータの取得を UseCase に要求

Slide 21

Slide 21 text

アーキテクチャ Eight の Model ViewModel UseCase API Client Realm Manager UseCase が Web API 経由でデータを取得

Slide 22

Slide 22 text

アーキテクチャ Eight の Model ViewModel UseCase API Client Realm Manager 取得したデータを Realm で永続化 Save as Realm Objects

Slide 23

Slide 23 text

アーキテクチャ Eight の Model ViewModel UseCase API Client Realm Manager データの取得完了をイベントとして発⾏ RxSwift.Completable で API の成功・失敗を伝搬

Slide 24

Slide 24 text

アーキテクチャ Eight の Model ViewModel UseCase API Client Realm Manager Realm オブジェクトの追加・更新・削除を UseCase が購読 ドメインオブジェクトに変換して ViewModel に⾮同期に発⾏ Restore and convert to Domain Objects

Slide 25

Slide 25 text

Eight iOS では画⾯遷移の処理は Coordinator クラス が担当します。ViewModel が発⾏する画⾯遷移のイベ ントを受け取り、次に表⽰する ViewController に遷 移します。 画⾯遷移の処理を ViewController から Coordinator に委譲することで、ViewController は MVVM アーキ テクチャの View の責務に徹することができます。 アーキテクチャ Coordinator パターン Coordinator

Slide 26

Slide 26 text

アーキテクチャ Coordinator パターン Coordinator A ViewModel A Coordinator B ViewController B Navigation Event Present Start

Slide 27

Slide 27 text

Eight iOS の実装例

Slide 28

Slide 28 text

• Web API で イベントの⼀覧を取得 • 取得したデータをキャッシュとして Realm に保存 • リストにデータを表⽰ • リスト要素のタップでイベントの詳細に遷移 Eight iOS の実装例 ONAIR 中のイベント

Slide 29

Slide 29 text

Eight iOS の実装例 Model Domain Object API Request Realm Object UseCase

Slide 30

Slide 30 text

@objcMembers class OnairEvent: RLMObject { dynamic var eventID = 0 dynamic var title = "" dynamic var descriptionString = "" dynamic var url = "" dynamic var startAt = Date() override class func primaryKey() -> String { "eventID" } } Eight iOS の実装例 Model Realm Object

Slide 31

Slide 31 text

Eight iOS の実装例 Model API Request struct GetOnairOnairEventsRequest: RequestType { let method = HTTPMethod.GET let path = "/onair/onair_events" func responseFromObject(_ object: AnyObject) -> [OnairEvent]? { guard let response = object as? [String: Any], let events = response["events"] as? [[String: Any]] else { return nil } return events.compactMap(OnairEvent.init) } }

Slide 32

Slide 32 text

Eight iOS の実装例 Model Domain Object struct Event: Equatable { let eventID: EventID let title: String let description: String let url: URL let startAt: Date }

Slide 33

Slide 33 text

公開インターフェースとして Fetchable, Loadable のプロト コルを定義し、UseCase がプロトコルに適合します。 - Fetchable:取得処理を提供 - Loadable:キャッシュ復元処理を提供 UseCase UseCase Fetchable Loadable Eight iOS の実装例

Slide 34

Slide 34 text

Fetchable RxSwift.Completable で Web API の取得の完了 or 失敗を表現します。 import RxSwift public protocol EventsFetchable { func fetch() -> Completable } Eight iOS の実装例

Slide 35

Slide 35 text

Loadable RxSwift.Observable で Realm Object の追加・更新時にドメインオブジェクトを発⾏します。 import RxSwift public protocol EventsLoadable { func load() -> Observable<[Event]> } Eight iOS の実装例

Slide 36

Slide 36 text

final class OnairEventsUseCase: EventsFetchable, EventsLoadable private let realmManager: RealmManaging private let apiClient: APIClientType init(realmManager: RealmManaging, apiClient: APIClientType) { self.realmManager = realmManager self.apiClient = apiClient } func fetch() -> Completable { fatalError(”TODO: Implementation") } func load() -> Observable { fatalError(”TODO: Implementation") } } Eight iOS の実装例

Slide 37

Slide 37 text

func fetch() -> Completable { apiClient .rx_request(GetOnairOnairEventsRequest()) .flatMapCompletable { [realmManager] events -> Completable in realmManager.execute { realm in realm.addOrUpdateObjects(events) } } } Eight iOS の実装例

Slide 38

Slide 38 text

func load() -> Observable<[Event]> { realmManager.readAndObserveUpdate { realm in OnairEvent.allObjects(in: realm) }.map { onairEvent in onairEvent.map(Event.init) } } Eight iOS の実装例

Slide 39

Slide 39 text

ViewStates, NavigationEvents のプロトコルに適合します。 - ViewStates:表⽰データを View に提供 - NavigationEvents:遷移イベントを Coordinator に提供 ViewModel は UseCase の具体型ではなく、 その抽象である Fetchable と Loadable に依存します。 Domain Object を View 表⽰⽤オブジェクトに編集します。 ViewModel View Model View States Navigation Events Fetchable Loadable Eight iOS の実装例

Slide 40

Slide 40 text

ViewItem View 表⽰⽤のデータ構造 struct EventListItem { let eventID: EventID let timeString: String let titleString: String } Eight iOS の実装例

Slide 41

Slide 41 text

ViewStates RxSwift.Driver で View 表⽰⽤のデータを提供 import RxSwift import RxCocoa protocol EventsViewStates { var items: Driver<[EventListItem]> { get } } Eight iOS の実装例

Slide 42

Slide 42 text

NavigationEvents RxSwift.Driver で画⾯遷移イベントを発⾏ import RxSwift import RxCocoa protocol EventsNavigationEvents { var showEventDetail: Driver { get } var showError: Driver { get } } Eight iOS の実装例

Slide 43

Slide 43 text

final class EventsViewModel: EventsViewStates, EventsNavigationEvents { let items: Driver<[EventListItem]> let showEventDetail: Driver let showError: Driver struct Dependency { let fetcher: EventsFetchable let loader: EventsLoadable } struct UIEvents { let eventTapped: Observable let viewWillAppear: Observable } init(dependency: Dependency, events: UIEvents) { fatalError("TODO: Implementation") } } Eight iOS の実装例

Slide 44

Slide 44 text

init(dependency: Dependency, events: UIEvents) { self.showError = events .viewWillAppear .flatMapFirst { dependency.fetcher.fetch() .andThen(Observable.just(())) .materialize() .compactMap { $0.error } } .map { _ in () } .asDriver(onErrorDriveWith: .empty()) self.items = dependency.loader.load() .map { $0.map(EventListItem.init(event:)) } .asDriver(onErrorDriveWith: .empty()) self.showEventDetail = events.eventTapped .asDriver(onErrorDriveWith: .empty()) } Eight iOS の実装例

Slide 45

Slide 45 text

ViewModel の抽象である ViewStates に依存します。 ViewStates が発⾏する表⽰⽤の値を ViewController でデータバインディングします。 View View ( UIViewController ) ViewStates subcribe publisch Eight iOS の実装例

Slide 46

Slide 46 text

import UIKit import RxSwift import RxCocoa final class EventsViewController: UIViewController { @IBOutlet private weak var tableView: UITableView! private var items: [EventListItem] = [] private let disposeBag = DisposeBag() var eventTapped: Observable { fatalError("TODO: Implementation") } var viewWillAppear: Observable { fatalError("TODO: Implementation") } func bind(viewStates: EventListViewStates) { fatalError("TODO: Implementation") } } Eight iOS の実装例

Slide 47

Slide 47 text

var eventTapped: Observable { tableView.rx.itemSelected.compactMap { [weak self] indexPath in self?.items[indexPath.row].eventID } } var viewWillAppear: Observable { self.rx.methodInvoked(#selector(viewWillAppear(_:))) .map { _ in () } } func bind(viewStates: EventListViewStates) { viewStates.items.drive(onNext: { [weak self] items in self?.items = items self?.tableView.reloadData() }) .disposed(by: disposeBag) } Eight iOS の実装例

Slide 48

Slide 48 text

NavigationEvents に依存します。 NavigationEvents が発⾏する画⾯遷移のイベントを 監視して、次の画⾯を表⽰します。 Eight iOS の実装例 Coordinator Coordinator NavigationEvents subcribe publisch

Slide 49

Slide 49 text

final class EventListCoordinator { private weak var parent: UIViewController? private let disposeBag = DisposeBag() init(parent: UIViewController) { self.parent = parent } func start() { // ⼀覧を表⽰ fatalError("TODO: Implement") } private func bind(navigationEvents: EventListNavigationEvents) { // 詳細を表⽰ fatalError("TODO: Implement") } } Eight iOS の実装例

Slide 50

Slide 50 text

Eight iOS の実装例 func start() { let (viewController, viewStates, navigationEvents) = makeEventList() viewController.bind(viewStates: viewStates) bind(navigationEvents: navigationEvents) parent?.show(viewController, sender: nil) }

Slide 51

Slide 51 text

private func makeEventList() -> ( EventsViewController, EventsViewStates, EventsNavigationEvents ) { // instantiate from storyboard. let controller: EventsViewController = // ... controller.loadViewIfNeeded() let viewModel = EventsViewModel( dependency: .init( fetcher: Container.sharedResolver.resolve(EventsFetchable.self)!, loader: Container.sharedResolver.resolve(EventsLoadable.self)! ), events: .init( eventTapped: controller.eventTapped, viewWillAppear: controller.viewWillAppear ) ) return (controller, viewModel, viewModel) } Eight iOS の実装例

Slide 52

Slide 52 text

Eight iOS の実装例 private func bind(navigationEvents: EventListNavigationEvents) { navigationEvents .showEventDetail .drive(onNext: { [weak self] eventID in self?.showEventDetail(eventID: eventID) }) .disposed(by: disposeBag) }

Slide 53

Slide 53 text

まとめ

Slide 54

Slide 54 text

• Eight では1週間のスプリントで開発 • 設計フェーズでクラス図を作成 • 内部実装の前段階で空のクラスをコミット • 実装フェーズはクラス単位で並⾏開発 • MVVM + Coordinator パターンで責務を明確化 まとめ Eight iOS アプリの開発⼿法

Slide 55

Slide 55 text

iOS Engineer Twitter @_take_hito_ VirtualCard ⼩清⽔ 健⼈