こんにちは。 Sansan iOS アプリエンジニアの中川です。
Sansan iOS アプリは今年の 6 月 15 日にメジャーバージョンアップをしました。 このバージョンアップにはオンライン名刺が目玉機能として含まれています。 オンライン名刺は昨今の新型コロナウイルスの流行から企業がテレワークやオンライン上での働き方への移行を迫られている中、名刺交換をオンラインで実施できない現状に対して、会社として向き合わなければならなかった重要な機能でした。
数字で見るオンライン名刺開発
項目 | 数字 |
---|---|
開発に関わった iOS アプリエンジニア | 9 人 (全員) |
開発期間 | 33 日 (営業日ベース) |
Pull Request 数 | 189 Pull Requests |
前バージョン (v5.6.2) からのコミット数 | 1567 commits |
前バージョン (v5.6.2) からの変更ファイル数 | 384 files |
改めて、数字を出してみると、多くのメンバーがオンライン名刺に向き合ってくれたことが分かります。 これほどの人数で一機能を作るのは自分の経験の中でもはじめてのことでいかに円滑に進めて、事業的に要求されている期日にコミットできるかプロジェクト当初に頭を悩ませていたことを思い出せます。
では、何がオンライン名刺の開発で有効だったか、チームで振り返って出た意見を以下に挙げます。
- 採用しているアーキテクチャとデザインとの親和性が高かった
- エラーハンドリング、ローディングについての指針ができた
採用しているアーキテクチャとデザインとの親和性が高かった
Sansan iOS アプリでは VIPER*1 と呼ばれるアーキテクチャを採用しています。 VIPER は一画面、一機能をモジュールとして扱い、責務を分離します。モジュール間のデータの受け渡しは Router を通して行われるため、渡すデータの方式だけメンバー間で合意が取れていれば、その後の画面、機能実装については独立して開発を進めることが出来ます。また、アプリに実装されたデバッグメニューで Router からダミーデータを差し込んで、遷移前画面の実装前に動作確認やデザイナーレビューを実施することが出来ました。
そして、以下がデザイナーが作成したプロトタイプです。 プロトタイプを見ると一目瞭然ですが、大量の画面から構成されています。この画面毎にモジュールを区切ることで並列数をチームメンバーの最大である 9 まで上げることが出来ました。
エラーハンドリング、ローディングについての指針ができた
Sansan iOS アプリでは今までエラーハンドリングやローディングは機能毎に UI / UX を考えながら、作ってきたため、共通の指針もなく、プロジェクトごと、実装者ごとに思い思いの実装がされてきました。そのため、コードの統一感はなく、コードレビューや影響範囲調査をする際に手間がかかるし、新機能を実装する度に固定の工数として積まれるため、開発のスピードに影響を与えていました。
しかし、オンライン名刺のプロジェクトにて、デザイナーとエンジニア間で共通の指針が作られ、それに則って、共通のコンポーネントができたため、エラーハンドリングやローディングの実装が楽になりました。まず、どのような指針を策定したか、紹介します。
画面初期化処理における状態遷移
状態 | UI |
---|---|
読み込み中 | インジケーターを中央に表示する。 |
ネットワークエラー | 通信エラーの旨が記載されたエラー画面が表示される。リトライ可能。 |
権限エラー | 権限エラーの旨が記載されたエラー画面が表示される。リトライ不可能。 |
ネットワークエラー、権限エラーでもないエラー | 不明なエラーの旨が記載されたエラー画面が表示される。リトライ不可能。 |
読み込み成功 | 各機能の画面が表示される。 |
ボタン押下に対するアクションにおける状態遷移
状態 | UI |
---|---|
読み込み中 (ボタン押下直後) | 半透明のグレーのレイヤを表示した上、インジケーターの表示を行う。 |
ネットワークエラー | 通信エラーの旨が記載されたアラートが表示される。 |
権限エラー | 権限エラーの旨が記載されたアラートが表示される。 |
ネットワークエラー、権限エラーでもないエラー | 不明なエラーの旨が記載されたアラートが表示される。 |
アクション成功 | アクション成功の旨のトーストを表示する。 |
上記の指針を満たすために作ったコンポーネントを紹介します。
ローディング編
読み込み画面のコンポーネントである ProgressView
import SansanUIComponent import UIKit final class ProgressView: UIView { @IBOutlet private weak var activityIndicatorView: UIActivityIndicatorView! private let animationDuration: TimeInterval = 0.1 private var frontWindow: UIWindow? { UIApplication.shared.keyWindow } private weak var containerView: UIView? private var contentView = UIView() var dataLoadBackgroundColor: UIColor = .white init(containerView: UIView?) { super.init(frame: .zero) self.containerView = containerView backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false loadNib() addConstraintsForContentView() } required init?(coder: NSCoder) { super.init(coder: coder) backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false loadNib() addConstraintsForContentView() } private func loadNib() { let view = R.nib.progressView(owner: self)! view.frame = bounds contentView = view addSubview(view) } private func show() { superview?.bringSubviewToFront(self) UIView.animate(withDuration: animationDuration, animations: { self.alpha = 1 }) activityIndicatorView.startAnimating() } private func setup(_ style: ProgressHUDStyle) { switch style { case .dataLoad: subviews.first?.backgroundColor = dataLoadBackgroundColor case .action: subviews.first?.backgroundColor = SansanColor.blackPanther.withAlphaComponent(0.7) } } private func fill(to view: UIView) { NSLayoutConstraint.activate([ topAnchor.constraint(equalTo: view.topAnchor), trailingAnchor.constraint(equalTo: view.trailingAnchor), bottomAnchor.constraint(equalTo: view.bottomAnchor), leadingAnchor.constraint(equalTo: view.leadingAnchor) ]) } private func fill(to scrollView: UIScrollView) { NSLayoutConstraint.activate([ topAnchor.constraint(equalTo: scrollView.frameLayoutGuide.topAnchor), trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor), bottomAnchor.constraint(equalTo: scrollView.frameLayoutGuide.bottomAnchor), leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor) ]) } private func addConstraintsForContentView() { NSLayoutConstraint.activate([ contentView.topAnchor.constraint(equalTo: topAnchor), contentView.trailingAnchor.constraint(equalTo: trailingAnchor), contentView.bottomAnchor.constraint(equalTo: bottomAnchor), contentView.leadingAnchor.constraint(equalTo: leadingAnchor) ]) } private func fillToWindow() { guard let window = frontWindow else { return } NSLayoutConstraint.activate([ topAnchor.constraint(equalTo: window.topAnchor), trailingAnchor.constraint(equalTo: window.trailingAnchor), bottomAnchor.constraint(equalTo: window.bottomAnchor), leadingAnchor.constraint(equalTo: window.leadingAnchor) ]) } } extension ProgressView: ProgressViewDisplayable { func show(with style: ProgressHUDStyle) { setup(style) if style == .action || containerView == nil { frontWindow?.addSubview(self) fillToWindow() } else { let view = containerView! view.addSubview(self) if let scrollView = view as? UIScrollView { fill(to: scrollView) } else { fill(to: view) } } alpha = 0 superview?.sendSubviewToBack(self) show() } func hide() { let closure: (Bool) -> Void = { finished in if finished { self.removeFromSuperview() } } UIView.animate(withDuration: animationDuration, delay: 0, animations: { self.alpha = 0 }, completion: { finished in closure(finished) }) } }
RxSwift のサンプルコードに含まれている ActivityIndicator
Sansan iOS アプリは API 通信のハンドリングに RxSwift を利用しています。 API 通信を行う前に処理を挟むことで容易にローディングを共通化することが出来ました。 ローディングを挟む Observable は RxSwift のサンプルコードに含まれている ActivityIndicator.swift を利用しています。
Sansan iOS アプリでは以下のように実装してます。
1. UIViewController と Presenter にローディングに必要なプロパティを追加
final class SomeViewController: UIViewController { // containerView は ProgressView を表示させたい view を指定する // デフォルトは view で OK private(set) lazy var progressView = ProgressView(containerView: view) } final class SomePresenter: SomePresenterInterface { private let indicator: ActivityIndicator private let indicatorObservable: Observable<Bool> private var progressHUDStyle: ProgressHUDStyle? }
2. UIViewController で ProgressView を ActivityIndicator に bind する
final class SomePresenter: SomePresenterInterface { func bindLoadingIndicator(isShowWithStyle: Binder<ProgressHUDStyle?>) { // 画面読み込みか、登録処理かでローディングの表示が異なるため、 style を変換 indicatorObservable.map { [weak self] isLoading in isLoading ? self?.progressHUDStyle : nil } .bind(to: isShowWithStyle) .disposed(by: disposeBag) } } final class SomeViewController: UIViewController { var presenter: ErrorHandlingSamplePresenterInterface! override func viewDidLoad() { super.viewDidLoad() presenter.bindLoadingIndicator(isShowWithStyle: progressView.rx.isShowWithStyle) } }
3. API リクエスト時に trackActivity メソッドを呼び出す
final class SomePresenter: SomePresenterInterface { private let indicator: ActivityIndicator private var progressHUDStyle: ProgressHUDStyle? func fetchSomeData() { progressHUDStyle = .dataLoad // <- 基本方針に従って書き換え interactor.fetchSomeData() .trackActivity(indicator) // <- これを追加 .observeOn(MainScheduler.instance) .subscribe { [weak self] event in // エラーハンドリング等 } } .disposed(by: disposeBag) }
エラーハンドリング編
関連するクラスが多いため、俯瞰できるように図で表現しました。 クラス間の依存関係を把握した上で以降の説明を読むと、理解が深まると思います。
それでは、 Sansan iOS アプリでどのようにエラーハンドリングを実装するのか説明しつつ、作ったコンポーネントに触れていきます。
1. ErrorPresentationModelBuilderInterface に API のエラーに準じた処理を書く
エラー画面を表示する FullScreenErrorView
は受け取った ErrorPresentationModel
を利用してエラーを表示します。
実装した API のエラーから ErrorPresentationModel
を生成するためにまずは ErrorPresentationModelBuilderInterface
を拡張します。
where E == SomeAPIError
とすることで特定のエラーに対しての処理を記述出来るようにしています。
実装するメソッドは build(restoreHandler: RestoreHandler?) -> ErrorPresentationModel
です。
共通 API エラー、ネットワークエラー、不明なエラーから ErrorPresentaionModel
を生成するメソッドはデフォルト実装があるため、ほとんどの場合は以下のように条件分岐をするだけで済むようになっています。
// ErrorPresentationModelBuilderInterface を拡張する // MARK: - Some API Error Handling extension ErrorPresentationModelBuilderInterface where E == SomeAPIError { func build(restoreHandler: ErrorPresentationModel.RestoreHandler?) -> ErrorPresentationModel { switch error { case let .commonAPIError(commonAPIError): return build(from: commonAPIError, restoreHandler: restoreHandler) case let .fetchFailed(error) where (error as? URLError)?.code == .timedOut: return buildTimeoutErrorPresentationModel(restoreHandler: restoreHandler) default: return buildUnknownErrorPresentationModel() } } }
2. UIViewController を Failable に適合させる
FullScreenErrorView
を各画面に表示させるために Failable
を採用します。
protocol Failable: AnyObject { var errorView: ErrorDisplayable { get } var errorContainerView: UIView { get } func showError(_ errorPresentationModel: ErrorPresentationModel, preferredStyle: ErrorStyle, animated: Bool) func hideError(animated: Bool) }
定義は上記のようになっていますが、errorContaienrView
プロパティと showError(_ errorPresentationModel: ErrorPresentationModel, preferredStyle: ErrorStyle, animated: Bool)
, hideError(animated: Bool)
の二つのメソッドについてはデフォルト実装があるため、特に挙動をいじる必要がなければ errorView
プロパティを定義するだけで良いです。
errorContainerView
はデフォルト実装では Failable
に適合した UIViewController の view を渡すようになっていますが、それではうまく表示されない場合に別の UIView に置き換えることが出来ます。
// Failable に適合させる final class SomeViewController: UIViewController { // デフォルトの view でうまくいかない時に実装 var errorContainerView: UIView { return someView } private(set) lazy var errorView: ErrorDisplayable = FullScreenErrorView(frame: .zero) } extension SomeViewController: Failable {}
3. ErrorPresentaionModel を生成
例えば Presenter に以下のようなメソッドを実装して ダウンキャストした Error を ErrorPresentationModelBuilder
のイニシャライザに渡します。
// SomePresenter.swift private func buildErrorPresentationModel(from error: Error) -> ErrorPresentationModel { switch error { case let error as SomeAPIError: return ErrorPresentaionModelBuilder(with: error).build(restoreHandler: { [weak self] in self?.view.hideErrorView() self?.fetchSomeData() }) default: return ErrorPresentaionModelBuilder(with: error).build(restoreHandler: nil) } }
なお、build(restoreHandler: RestoreHandler?) -> ErrorPresentationModel
メソッドの引数の restoreHandler
は リトライボタン
を押した時に実行されますが、不明なエラー、権限エラーの時にはエラーハンドリングの基本方針に沿って、リトライ不可能にする都合上、ボタンを非表示にしているため、利用していません。
このあとの手順は実装したエラーハンドリングのコンポーネントを VIPER でどう利用するかの手順になり、本筋から外れるため、説明は省き、サンプルコードの掲載のみにします。
4. ViewInterface を通して UIViewController に ErrorPresentationModel を渡す
// SomeViewController.swift protocol SomeViewInterface: ViewInterface { func loadingDidSucceed() func loadingDidFail(errorPresentationModel: ErrorPresentationModel) func hideErrorView() }
// SomePresenter.swift func fetchSomeData() { interactor.fetchSomeData() .observeOn(MainScheduler.instance) .subscribe { [weak self] event in guard let self = self else { return } switch event { case .success: self.view.loadingDidSucceed() case let .error(error): let presentationModel = self.buildErrorPresentationModel(from: error) // UIViewController に ErrorPresentationModel を渡す self.view.loadingDidFail(errorPresentationModel: presentationModel) } } .disposed(by: disposeBag) }
5. UIViewController に実装した ViewInterface のメソッドから showError メソッドを呼び出す
// SomeViewController.swift protocol SomeViewInterface: ViewInterface { func loadingDidSucceed() func loadingDidFail(errorPresentationModel: ErrorPresentationModel) func hideErrorView() } final class SomeViewController: UIViewController {} extension SomeViewController: SomeViewInterface { func loadingDidSucceed() { // 成功時の処理 } func loadingDidFail(errorPresentationModel: ErrorPresentationModel) { // デフォルトでない errorContainerView を利用しているときは必要 // errorContainerView.alpha = 1 // エラー画面を表示 showError(errorPresentationModel, preferredStyle: .fullScreen, animated: true) } func hideErrorView() { // デフォルトでない errorContainerView を利用しているときは必要 // errorContainerView.alpha = 0 // エラー画面を非表示 hideError(animated: true) } }
最後に
私事ですが、オンライン名刺の交換体験をアプリで実現するために採用した Firebase Dynamic Links で iOSDC Japan 2020 のプロポーザルを採択いただけました。
そちらの詳細はトークで発表しますのでお楽しみに。
*1:Sansan 主催の技術カンファレンス Sansan Builders Box 2019 にて VIPER の導入に関する発表をしました。 speakerdeck.com