iOS
MVVM
Swift
mastodon

friends.nico iOSのリリースと実装について

この記事は第2のドワンゴ Advent Calendar 2017 19日目の記事です。
昨日は同期入社でドワンゴ退職時期も同じ@abount_hiroppyさんで「botたちの家を作っている」でした。1
出戻りおめでとうございます:rocket:

はじめに

2017年4月頃Mastodonが日本で突如はやりだし、mstdn.jppawoo.netなどの多数のインスタンスが現れました。
ドワンゴでもfriends.nicoというインスタンスが建てられ、ドワンゴ公式としてiOSアプリがリリースされました。

もともとfriends.nico iOSは別のアプリ名で個人的に開発を行っていたもので、OSSとして流れにのってスターでも稼ぐかくらいの気持ちで開発を初めたのですが、ひょんなことから公式アプリとしてリリースすることになりました。
今回は今までfriends.nico iOSがどのように生まれたのか全く触れる機会がなかったのが寂しかったので少しリリースまでの道のりを紹介し、具体的な内部の実装について触れていきたいと思います。
なにも確認をとらずに書いてますのでそのうち消えたらすいません

リリースまでの道

当時はMastodonなんかいきなりはやりだしたなーという完全に傍観をきめていましたが、friends.nicoの立ち上げが2017年4月16日の明け方くらいにドワンゴslack上で話されていて、 そこから流れに乗ってみるかと何も考えずにアプリを作成しはじめました。
スクリーンショット.png

そこから地道にMastodon標準の機能をいれつつ実装していた所、nicoruが復活し、せっかくなのでfriends.nico独自の機能もリリースするかということでfriends.nico接続時のみニコれるようにしてみたりしてました。
この時、同時に超会議マストドンブースの設営が進んでおり、どうせならリリース日は超会議に合わせるという個人的な目標を立てました。

ここまでは完全に個人で開発しており、リリース準備のため知り合いのデザイナーにアプリのアイコンを依頼したりして準備をすすめてきました。
friends.nico iOSを初期から使用してくださっている皆さんの中には気づいている人がいるかもしれませんがこのアプリのリリース当初のOAuthのクライアント名は「Masvory」という名前でした。
この名前は個人アプリとして開発を行っていた時の名残でリリース時に変えるのを忘れていただけです:sob:

いつか使おうと思っていましたが多分一生使うことがないのでわざわざ用意していたアプリアイコンもこの記事で供養しておきます

R.I.P.
Icon-60@3x.png

そんなこともありつつ、4月24日(月)のドワンゴエンジニア社内LTでこのアプリを発表した所、friends.nico関係者の目にとまり紆余曲折あり、ドワンゴ公式アプリとしてリリースすることになりました。

ただし、公式アプリとしてリリースすることが決まったのが4月26日(水)であり、リリース日はできるだけ超会議29日(土)に合わせるという鬼のプランでした :thinking:

26日(水)の朝の時点で既に自分個人のAppleDeveloprアカウントにて審査提出済みでしたが、なんとか超会議開始の29日(土)の時点に

  • ドワンゴAppleDeveloperにアプリを移管させる
  • アプリ名、アイコン、各種アイコンを最低限friends.nicoに合わせる

ということで、様々なプランを考えた結果、以下のように審査を進めて最終的に無事リリースすることができました :tada:

  • 4/26(水) AM3時 Masvoryとして個人Developerアカウントで審査提出
  • 4/27(木) AM1時 審査通過
  • 4/27(木) AM4時 friends.nico iOSとして個人アカウントで審査提出
  • 4/27(木) AM4時 審査提出と同時に特急審査申し込み
  • 4/27(木) AM5時 メタデータリジェクト
  • 4/27(木) PM0時 メタデータを修正して再度提出
  • 4/28(金) AM2時 審査通過
  • 4/28(金) AM3時 個人アカウントのままAppStoreに公開
  • 4/28(金) 昼すぎ ドワンゴAppleDeveloperアカウントへアプリ移管
  • 4/29(土) 超会議本番 :tada: :tada:

ドワンゴ時代では何個か時間に追われながらのリリースを体験しましたがこのリリースが一番びびってました :scream:

friends.nico iOSを作り出す前からドワンゴの退職は決まっており、ドワンゴ史上最初で最後(であろう)ニコニコインフォで退職報告を決めることもでき恐ろしく濃い退職前の1ヶ月という貴重な体験もすることができました。

結果的に今もfriends.nico iOSアプリの開発やメンテナンスを、業務委託ではありますが引き続き行うことができており良かったです :kissing_heart:

思い出話はこれくらいにして実装の話に移りたいと思います。

friends.nicoとMVVM

friends.nico iOSはRxSwiftを使用したMVVMアーキテクチャで作成されています。
個人的に作成しているアプリの多くをMVVMで実装していますが、このMVVMの実装に大きく影響を受けているのがoss化されたkickstarter-iosです。

kickstarter-iosは実際にはこちらのissueにあるように純粋なMVVMとはいえないようですが、開発チームが主としてる入力信号や出力信号についてやテストの可用性の考え方など非常に共感出来る部分が多く、実装に取り入れています。

InputとOutputのあるViewModel

アプリのロジックが大きくなってくると共にViewModelに対するインプットとアウトプットが混ざってしまい予期せぬバグが入ってしまうことがあります。
このコードはかなり極端な例ですが、

ViewModel.swift
final class ViewModel {
    let trigger = PublishSubject<Void>()
}
ViewController.swift
final class ViewController: UIViewController {

    @IBOutlet private weak var button: UIButton!
    private let viewModel = ViewModel()
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        button.rx.tap.asDriver()
            .drive(viewModel.trigger)
            .disposed(by: disposeBag)
    }
}

このような状態で

ViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()
    button.rx.tap.asDriver()
        .drive(viewModel.trigger)
        .disposed(by: disposeBag)
    // Inputに対してsubscribeしてしまっている
    viewModel.trigger.asObservable()
        .subscribe(onNext: { /*ロジック*/ }
        .disposed(by: disposeBag)
}

このようなわかりやすい形ではありませんが、本来はViewModelに対してのインプットのみを行うようにしているトリガーをController側がアウトプットとして使用してしまう問題をいくつかのチームで経験しました。

これはインプットのプロパティ名などである程度予防することはできますが、動作上問題はなくロジックが多岐に渡っている場合検知するのがなかなか難しいです。

ViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()
    button.rx.tap.asDriver()
        .filter { /*フィルター*/ }
        .drive(viewModel.trigger)
        .disposed(by: disposeBag)
}

この状態でフィルターなどをインプットに対して行ってしまった際にバグが初めて発生してしまいデバッグが非常に困難になってしまいます。

そもそもこのような状態にするべきではないのですが、実際の開発では極稀にこのようなケースが発生してしまっていました。

kickstarter-iosではこのような問題に対して、ViewModelに InputsOutputs をプロパティとして生やすことで解決を図っています。
Kickstarter-iOSのViewModelの作り方がウマかったでも詳しく記載されていますが、そもそもこの問題はViewModelのインプットが AnyObserver 、アウトプットが Observable だけになれば起こらないですが現実の開発でそのように行うことは困難です。

そこでViewModelに明示的に InputsOutputs を定義することでController側からインプットとアウトプットをよりわかりやすい表現しています。

ViewModel.swift
protocol ViewModelInputs {
    var trigger: PublishSubject<Void> { get }
}
protocol ViewModelOutputs {
    var value: Driver<String> { get }
}
protocol ViewModelType {
    var inputs: ViewModelInputs { get }
    var outputs: ViewModelOutputs { get }
}

final class ViewModel: ViewModelType, ViewModelInputs, ViewModelOutputs {

    var inputs: ViewModelInputs { return self }
    var outputs: ViewModelOutputs { return self }

    // Inputs
    let trigger = PublishSubject<Void>()
    // Outputs
    let value: Driver<String>

    init() {
        self.value = trigger.asDriver(onErrorDriveWith: .empty()).map { _ in "value" }
    }

}
ViewController.swift
final class ViewController: UIViewController {

    @IBOutlet private weak var button: UIButton!
    private let viewModel: ViewModelType = ViewModel()
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        button.rx.tap.asDriver()
            .drive(viewModel.inputs.trigger)
            .disposed(by: disposeBag)
        viewModel.outputs.value
            .drive(onNext: {})
            .disosed(by: disposeBag)
    }
}

重要な点は ViewController 側のViewModelの宣言をprotocolにするという点です。
こうすることによってControllerは inputsoutputs 経由でしかプロパティにアクセスすることができません。

ViewController.swift
private let viewModel: ViewModelType = ViewModel()

その他

この他インプットとアウトプットを区別する方法として、以下のようにViewModelのinitializerにinputsを差し込む等方法がありますが、このケースだと突発的に現れるアラートなどのインプットをViewModelに綺麗に伝える手段を思い浮かばずに使用を限定して要所要所で使用しています。綺麗な方法を知ってる方アドバイスお願いします :pray:

ViewModel.swift
final class ViewModel {
    init(inputs: (reloadTrigger: Driver<Void>, reachedBottomTrigger: Driver<Void>)) {
    }
}

DIコンテナ

friends.nico iOSは開発からリリースまで約1週間とかなりの短い期間でメインとなるロジックを実装しなければなりませんでした。
このような状態でテストコードを書くことは不可能でしたが、できるだけ後々テストが書きやすいような構造にしておくことは非常に重要でした。

ここでkickstarterの環境変数の仕組みを利用しました。
この構造は2017年のtry! Swiftで発表されたテスト可能なコードを書くということの2つの側面という発表で詳しく知ることができます。

テストを容易にするため、協作用を起こすものをコンテナとして1つのstructに入れて管理しています。
実際のkickstarterで使用されているケースはこのような形となっています。
APIClientや、Foundationで提供されているUserDefaultsまで協作用を起こすものを格納しています。

Environment.swift
struct Environment {

    let session: SessionProtocol
    let cookieStorage: HTTPCookieStorageProtocol
    let defaults: KeyValueStoreType

    init(session: SessionProtocol = .shared,
         cookieStorage: HTTPCookieStorageProtocol = HTTPCookieStorage.shared,
         defaults: KeyValueStoreType = .standard) {

        self.session = session
        self.cookieStorage = cookieStorage
        self.defaults = defaults
    }

}
AppEnvironment.swift
struct AppEnvironment {

    private static var stack: [Environment] = [Environment()]

    static var current: Environment {
        return stack.last ?? Environment()
    }

    static func pushEnvironment(_ env: Environment) {
        stack.append(env)
    }

    @discardableResult
    static func popEnvironment() -> Environment? {
        return stack.popLast()
    }

    static func replaceCurrentEnvironment(_ env: Environment) {
        pushEnvironment(env)
        stack.remove(at: stack.count - 2)
    }

    static func pushEnvironment(session: SessionProtocol = current.session,
                                cookieStorage: HTTPCookieStorageProtocol = current.cookieStorage,
                                defaults: KeyValueStoreType = current.defaults) {

        pushEnvironment(Environment(session: session,
                                    cookieStorage: cookieStorage,
                                    defaults: defaults))
    }

    static func replaceCurrentEnvironment(session: Session = current.session,
                                          cookieStorage: HTTPCookieStorageProtocol = current.cookieStorage,
                                          defaults: KeyValueStoreType = current.defaults) {

        replaceCurrentEnvironment(Environment(session: session,
                                              cookieStorage: cookieStorage,
                                              defaults: defaults))
    }
}

正確な意味ではありませんが、簡単に言い換えると、既存の実装でシングルトンになっているクラスやオブジェクトを全て1つの環境変数の中にいれ、差し替え可能にすることでテストの可用性を上げています。

実際の使用ケースでは

ViewModel.swift
Observable.just(())
    .flatMap { AppEnvironment.current.session.rx.response(request) }
    .subscribe()
    .disposed(by: disposeBag)

このような使い方となります。
こういったケースでシングルトンを使用してしまっているとテスト実行時のスタブ化やデータの保存のテストなどが非常に行いにくくなってしまいます。

この状態で session を差し替えつつもテストを行う場合は このようなヘルパーを用意することで

Tests.swift
func test() {
    withEnvironment(session: TestSession())) {
       /* このクロージャーの中ではAppEnvironmentの中のTest用のセッションのみ差し替わっている */
    }
}

上記のように容易に環境変数を切り替えつつもテストを行うことができます。

このkickstarterのスタイルはDIコンテナとしてシングルトンのオブジェクト等を1つにまとめただけですが、実際のアプリ開発でかなりの役にたち、アプリケーション開発を楽にしてくれます。
既存のプロジェクトですぐにDIなどを入れることができない場合も、テストをできるだけ行いやすくするといった意味で、まずこのようにアプリ内のシングルトンを1つのstructに集中させ切り替え可能にする仕組みを作成してみてはどうでしょうか。

まず、このような実装を行っていくことでテストを実行可能にする土台をしっかりと作成し、プロジェクトが進んでいきEnvironmentの肥大化などが起こった際はDIライブラリの導入を検討するなどすればいいと思います。

このような処理はシングルトンと実装コストはほぼかわりません。
重要なことはテストができる土台しっかりと作成することです。テストをすぐに書けないにしろ、テストを実行できるような環境を整えていくことは非常に大切なことですのでぜひ検討してみてください。

Actionによるストリームの分岐

ここからはkickstarterの実装は関係ないです :bow:
RxSwiftを使用していてよくある問題がエラーが起こりうるストリームをそのままにしていまいbind等をしているストリームが止まってしまうことです。この問題はエラーが起こった時に初めて起こる問題であり、デバッグ中になかなか気づきにくい場合があります。

friends.nico iOSではこの問題が起こらないようにViewModel内のエラーが起こりうる処理は必ずRxSwiftCommunity/Actionを使用してラップしています。
Actionを使用することで、処理の 結果エラー実行中ステータス を別々のストリームとして取り出すことができます。

ViewModel.swift
final class ViewModel {

    private let action: Action<Input, Output>

    init() {
        self.action = Action { input in
            return /* onErrorやonCompleteが起こりうるObservable */
        }
        // APIの結果
        action.elements
            .subscribe(onNext: { /*成功時の値のみが流れてくる*/ })
            .disposed(by: disposeBag)
        // APIが失敗したとき
        action.errors
            .subscribe(onNext: { /*エラー時のみ流れてくる*/ })
            .disposed(by: disposeBag)
        // 実行中
        action.executing
            .subscribe(onNext: { /*実行中のBool*/ })
            .disposed(by: disposeBag)
    }

}

Action のクロージャー内でエラーが発生しても action.errorsonNext() として値が流れてくるためストリームが止まることなく処理を実行することができます。

エラー処理はアプリ全体の実装の後のほうに実装することが多くなる場合が多々ありますが、後々通常のストリームに対して catchError 等を挟んでいく処理はエンバグが発生してしまうことも多く、はじめからエラーのストリームが分岐されている状態にしておくのはかなり効いてきます。
Actionを使用せずともメルカリアッテのRxSwift実装ガイドのような正常系とエラー系をストリームを途切れさすことなく分岐させる実装は開発初期から導入した方が良いと個人的に考えています。

DriverによるOutput

ViewModelのOutputには必ず RxCocoa.Driver を使用しています。
これもよくある問題ですが、 Observable のまま使用すると onNext のタイミングと subscribe のタイミングの違いで値が流れてこない問題が度々発生していました。

そもそもViewModelのOutputはViewなどに直接つなげることが多く、元から Driver である方が総合的に見て利点が大きいと感じたからです。

また、Outputの初期化は必ずViewModelのinitializerで行っています。

ViewModel.swift
final class ViewModel {

    let input = PublishSubject<Void>()
    let output: Driver<String>

    init() {
        self.output = input.map { _ in "value" }
                        .asDriver(onErrorDriveWith: .empty())
    }
} 

ViewModel等のアウトプットをこのように getter で定義しているケースを見かけることがありますが、これはDriverの使用元で複数のストリームを作成されてしまうケースがあり、個人的におすすめしません。

ViewModel.swift
var output: Driver<String> {
    return input.map { _ in "value" }
            .asDriver(onErrorDriveWith: .empty())
}
ViewController.swift
viewModel.output
    .drive()
    .disposed(by: disposeBag)
viewModel.output
    .drive()
    .disposed(by: disposeBag)

上記のような場合 output のストリームが2個作成されてしまっており、多くの場合予期せぬ多重処理となってしまいます。
この場合、Driverの利点を受けつつ正しく使用するには

let output = viewModel.output
outout
    .drive()
    .disposed(by: disposeBag)
outout
    .drive()
    .disposed(by: disposeBag)

のように行わなければなりません。

このようなことをController側で意識するのはできれば行いたくないため、初めからinitializerでアウトプットを定義することで同じストリームを使用することができます。

おわりに

friends.nico iOSはアイコンやアプリ名からfriends.nicoのインスタンスでしか使用できないアプリだと思われがちですが、マルチアカウントに対応しており、他のインスタンスでも十分に使用することができます。
pawoo.netのメディアタイムラインやおすすめユーザ等、その他インスタンスの特徴的な機能もどんどんアプリに組み込んでいますので、friends.nicoのユーザ以外の方もぜひ一度お試しください。

既にドワンゴを退職した身ではありますが、friends.nicoという新しいコミュニティにアプリで少しでも貢献できるように、これからもできるだけ機能の追加や改善などを行っていきたいと思います :santa_tone3:


  1. 自分も後を追ってか来年4月から無職です