[iOS] @cosme/@cosme PROアプリで使っている技術

弊社では先日のプレスリリースのとおり、@cosme, @cosme PRO for Beauty Specialistの2つのアプリをリリースしました。

今回はこの2つのアプリで採用したアーキテクチャやライブラリを一部紹介したいと思います。iOS版に限った話が多いですので、あらかじめご了承ください。

言語

Swift3系で書かれています。

最近@cosme PROアプリのほうをSwift4でコンパイルできるようにしました。

アーキテクチャ

両アプリとも、Clean ArchitectureとMVVMを参考にした設計にしました。

ユーザーの入力を用いてAPIを叩き、取得したデータの一部を端末内に保存しつつView用のモデルに変換してViewで表示する、といったよくある処理を、各層での役割が明確になるように記述することができます。

詳しくは アイスタイル的 iOS設計ベストプラクティス[qiita] をご覧ください。

導入がすんなりいったわけではなく、開発当初は複数のアーキテクチャについて勉強しつつ、チームで認識が合うまでたくさん話し合いを重ね、この設計に落ち着きました。

環境まわり

fastlane

iTunesStoreへのアップロードやDeploygateを使った配信などの自動化、PUSH通知用証明書の発行などに活用しています。

fastlaneのおかげで、私が途中からPJTに入ってもこれらの作業をすんなりと遂行できるようになったので、作業効率化はもちろん属人化軽減にも貢献しています。

CocoaPods, Carthage

ライブラリ管理はこの2つを使っています。もともと@cosmeアプリがCocoaPodsで管理していたためそのまま採用していますが、最近ではビルド時間短縮を狙い、少しずつCarthageに移行しています。

ライブラリ

2つのアプリではたくさんオープンソースのライブラリを使っています。

SwiftLint, R.swift, AlamofireObjectMapper, SwiftyJSON, SwiftyUserDefaults, Crashlytics, CryptoSwift, TextAttributes, FMDB

全てはあげきれませんので、その中で3つ、応用例も交えて紹介します。

RxSwift

上述したアーキテクチャの実現に役立っているのがRxSwiftです。

ViewModel <-> Viewのデータバインディングはもちろん、データを受け渡しする際はObservableにし、アプリケーション内でストリームとして処理できるようにしています。

またRxを採用することによって、iOS/Android開発者間でRxを通じて同じ文脈での会話ができた、という効果もありました。

Rx自体はアプリケーション全体で使われていますが、使用例としてここでは、一定時間ごとに実行したい処理をあげてみます。今回のアプリでいうと、一般ユーザーとスペシャリストとがメッセージで繋がることができるメッセージ機能があり、そのメッセージのポーリングなどに使っています。

例えば下記のように5秒ごとのタイマーを設置し、

timer = Observable<Int>.interval(5.0, scheduler: MainScheduler.instance).shareReplay(1)

タイマーのイベントを拾ったらflatMapFirstオペレータなどでメッセージを取得しにいく、という感じです。

var messageFetched: Observable<[Messages]> {
    return timer.flatMapFirst { (_) -> Observable<[Messages]> in {
        APIClient.fetch()
    }
}

ただ、基本ViewModelとViewのライフサイクルは同じにしているため、これだとViewが生きている限りタイマーも動き続けてしまいます。

今回の2つのアプリはUITabBarControllerによるタブアプリケーションになっており、何もしなければUITabBarControllerのタブの切り替えで画面が隠れてもViewは生き続けます。

隠れている間Observableを止めるには、下記のようにviewWillDisappearのタイミングでDisposeBagを破棄すれば良いです。viewWillAppearで再代入が必要なことに注意しましょう。

ViewController
private var disposeBag: DisposeBag! = DisposeBag()

override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        if disposeBag == nil {
            disposeBag = DisposeBag()
        }

        // 購読処理...
}

override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        disposeBag = nil
    }

これで、画面が隠れるとタイマーが止まるようになり、要件的に無駄な通信などをカットすることができます。

XLPagerTabStrip

XLPagerTabStripはスワイプでの画面遷移を簡単に実現できるいい感じのライブラリです。

タブやバーの色など、比較的自由にカスタマイズできますが、同一タブ上に動的にバッヂを付けたり外したりする挙動はライブラリ側ではサポートされていなかったため、下記のような方法で実装しています。

import UIKit
import XLPagerTabStrip

final class TabViewController: ButtonBarPagerTabStripViewController {

    private let badgeView = UIView()

    override func viewDidLoad() {
        super.viewDidLoad()

        badgeView.frame = CGRect(x: 0, y: 0, width: 10, height: 10)
        badgeView.backgroundColor = UIColor.red
        badgeView.isUserInteractionEnabled = false
        view.addSubview(badgeView)
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        badgeView.center = CGPoint(x: view.frame.width * 0.25 + 68, y: 10)
        view.bringSubview(toFront: badgeView)
    }
}

結局のところUIViewなのでこういった愚直な方法でも実現できます。一例として受け取っていただけると幸いです。

XLPagerTabStripでのタブ切り替え時にもviewWillDisappearが呼ばれるので、@cosme PROアプリではそのタイミングでバッヂの表示/非表示の更新をかけています。

LoadMoreTableViewController

LoadMoreTableViewControllerは、追加読み込み可能なリスト表示を実現するライブラリで、弊社で作成しOSSとして公開しています(PRお待ちしております!)。

両アプリで追加読み込みとなっている部分はほぼ全てこのLoadMoreTableViewControllerによって実装されています。

使用例ですが、@cosme PROアプリでは下の画像のように、1つの画面に複数種類のリソースを表示し、かつ一部を追加読み込みに対応させた画面がいくつか存在しています。

このUIを実現するにあたり、LoadMoreTableViewControllerにはいくつか問題がありました。

  • 複数種類のリソースを想定していない
  • Sectionを使えない

このUIの実現方法は、ライブラリを改良するとか、追加読み込み不要な部分をTableHeaderViewとして作成するとか、いくつかありますが、今回は画面全体を擬似的に追加読み込みの対象とすることにしました(A->B->C->C->C->…)。

実装例ですが、下記のようにLoadMoreTableViewControllerに準拠したうえで、rowを使って取り出したリソースのキャスト結果からどのオブジェクトかを判別するようにします。上述のとおり、Sectionが使えないためです。

ViewController
import UIKit
import RxSwift
import LoadMoreTableViewController

final class SampleTableViewController: LoadMoreTableViewController {

    private let disposeBag = DisposeBag()
    private let viewModel = SampleViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        /* ~~~省略~~~ */

        // MARK: - LoadMoreTableViewControllerの設定

        fetchCellReuseIdentifier = { [weak self] row -> String? in
            if let _ = self?.sourceObjects[row] as? ObjectA {
                return "A"
            } else if let _ = self?.sourceObjects[row] as? ObjectB {
                return "B"
            } else {
                return "C"
            }
        }

        configureCell = { [weak self] cell, row in
            if let objectA = self?.sourceObjects[row] as? ObjectA {
                (cell as? ACell)?.configure(objectA)
            } else if let objectB = self?.sourceObjects[row] as? ObjectB {
                (cell as? BCell)?.configure(objectB)
            } else if let objectC = self?.sourceObjects[row] as? ObjectC {
                (cell as? CCell)?.configure(objectC)
            } 
            return cell
        }

        fetchSourceObjects = { [weak self] completion in
            self?.fetchData(onSuccess: { objects, hasNext in
                _ = completion(objects, hasNext)
            }, onFailure: {
                _ = completion([], false)
            })
        }

    }

    private func fetchData(onSuccess: @escaping ([Any], Bool) -> Void, onFailure: @escaping () -> Void) {
        viewModel.fetch().subscribe(onNext: { [weak self] result in
                switch result {
                case .successA(let objectA):
                    DispatchQueue.main.async {
                        self?.tableView.reloadData()
                    }
                    // 以下tableView.reloadData()は省略
                    onSuccess([objectA], true)
                case .successB(let objectB):
                    onSuccess([objectB], true)
                case .successC(let objectCList):
                    onSuccess(objectCList.items, objectCList.hasMore)
                case .failure(let message):
                    onFailure()
                }
            }).disposed(by: disposeBag)
    }
}

ViewModelでは現在のページ番号を保持し、ページ番号によって呼び出すAPIを変えてあげます。

ViewModel
import Foundation
import RxSwift

final class SampleViewModel {

    enum Result {
        case successA(ObjectA)
        case successB(ObjectB)
        case successC(ObjectC)
        case failure(String)
    }

    private var page: Int = 0

    init() {}

    func fetch() -> Observable<Result> {
        if page == 0 {
            return APIClient.fetchA()
                .map({ (objectA) -> Result in
                    self.page += 1
                    return Result.successA(objectA)
                })
                .catchError { error in
                    return Observable.just(.failure(error.localizedDescription))
            }
        } else if page == 1 {
            return APIClient.fetchB()
                .map({ (objectB) -> Result in
                    self.page += 1
                    return Result.successB(objectB)
                })
                .catchError { error in
                    return Observable.just(.failure(error.localizedDescription))
            }
        } else {
            return APIClient.fetchC()
                .map({ (objectC) -> Result in
                    self.page += 1
                    return Result.successC(objectC)
                })
                .catchError { error in
                    return Observable.just(.failure(error.localizedDescription))
            }
        }
    }
}

これで、1つの画面に複数種類のリソースがある場合でもLoadMoreTableViewControllerのお作法に従いつつ記述することができるようになりました。

おわりに

いかがでしたでしょうか。

アーキテクチャや周辺サービス、ライブラリなどは、実際に実務で採用したからこそわかることも多々あるかと思います。本記事で紹介したものは、今のところ今回リリースした2つのアプリに欠かせないものになっています。

紹介できたものはほんの一部ですが、アーキテクチャやライブラリを選ぶ際の参考に少しでもなれば幸いです。

iOSエンジニア、新卒入社4年目 社内腕相撲大会で優勝しました Flutterが趣味