Sansan Builders Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

【オンライン名刺開発の裏側】iOS アプリ開発で良かったことを紹介!

こんにちは。 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 からダミーデータを差し込んで、遷移前画面の実装前に動作確認やデザイナーレビューを実施することが出来ました。

f:id:ynakagawa33:20200710130151p:plain
Sansan iOS アプリの VIPER Diaglam。
Router での DI 後は他モジュールとの接点を持たない。

f:id:ynakagawa33:20200727190351p:plain
Sansan iOS アプリのデバッグメニュー

そして、以下がデザイナーが作成したプロトタイプです。 プロトタイプを見ると一目瞭然ですが、大量の画面から構成されています。この画面毎にモジュールを区切ることで並列数をチームメンバーの最大である 9 まで上げることが出来ました。

f:id:ynakagawa33:20200710155531p:plain
オンライン名刺のプロトタイプ

f:id:ynakagawa33:20200727152534p:plain
オンライン名刺に関わるモジュール群

エラーハンドリング、ローディングについての指針ができた

Sansan iOS アプリでは今までエラーハンドリングやローディングは機能毎に UI / UX を考えながら、作ってきたため、共通の指針もなく、プロジェクトごと、実装者ごとに思い思いの実装がされてきました。そのため、コードの統一感はなく、コードレビューや影響範囲調査をする際に手間がかかるし、新機能を実装する度に固定の工数として積まれるため、開発のスピードに影響を与えていました。

しかし、オンライン名刺のプロジェクトにて、デザイナーとエンジニア間で共通の指針が作られ、それに則って、共通のコンポーネントができたため、エラーハンドリングやローディングの実装が楽になりました。まず、どのような指針を策定したか、紹介します。

画面初期化処理における状態遷移

状態 UI
読み込み中 インジケーターを中央に表示する。
ネットワークエラー 通信エラーの旨が記載されたエラー画面が表示される。リトライ可能。
権限エラー 権限エラーの旨が記載されたエラー画面が表示される。リトライ不可能。
ネットワークエラー、権限エラーでもないエラー 不明なエラーの旨が記載されたエラー画面が表示される。リトライ不可能。
読み込み成功 各機能の画面が表示される。

f:id:ynakagawa33:20200727140126g:plain
サンプルアプリでの動き

ボタン押下に対するアクションにおける状態遷移

状態 UI
読み込み中 (ボタン押下直後) 半透明のグレーのレイヤを表示した上、インジケーターの表示を行う。
ネットワークエラー 通信エラーの旨が記載されたアラートが表示される。
権限エラー 権限エラーの旨が記載されたアラートが表示される。
ネットワークエラー、権限エラーでもないエラー 不明なエラーの旨が記載されたアラートが表示される。
アクション成功 アクション成功の旨のトーストを表示する。

f:id:ynakagawa33:20200727135430g:plain
サンプルアプリでの動き

上記の指針を満たすために作ったコンポーネントを紹介します。

ローディング編

読み込み画面のコンポーネントである 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 を利用しています。

github.com

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

エラーハンドリング編

関連するクラスが多いため、俯瞰できるように図で表現しました。 クラス間の依存関係を把握した上で以降の説明を読むと、理解が深まると思います。

f:id:ynakagawa33:20200722163757p:plain
エラーハンドリング周りの UML

それでは、 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 LinksiOSDC Japan 2020 のプロポーザルを採択いただけました。

そちらの詳細はトークで発表しますのでお楽しみに。

fortee.jp

*1:Sansan 主催の技術カンファレンス Sansan Builders Box 2019 にて VIPER の導入に関する発表をしました。 speakerdeck.com

© Sansan, Inc.