新宿駅"ぐるぐる問題" ユーザー体験の改善に挑んだのはインターン!!!

株式会社ビズリーチに今年4月入社した立柳紀林(タマセン)です。
10月頃から内定者インターンとして働き始めて、3月末までBizHint事業部でiOSアプリの開発をしていました。
エンジニアとして内定をもらいましたが、アプリ開発未経験なので実務経験を積むため(と、半年留年しちゃって変な時期に卒業することになったので)、お願いして、インターンとして働かせてもらっていました。

インターンのある日

エンジニアのホリさんが、新宿駅山手線ホームでBizHintのアプリを起動すると通信中のぐるぐるが止まらなくなって、それ以後操作できなくなってしまうことを発見しました。

新宿駅山手線ホームではかなりの確率で “ぐるぐる問題” が発生するようで、同じように通勤中にアプリを使ったユーザーは同じような体験をしているはずです。 BizHintは、ビジネスパーソンを対象としたニュースアプリであるため、通勤中のユーザー体験の悪さはとても大きな問題でした。

この問題を解決せよというタスクがぼくのところに回ってきたのです。

何をすればいいか考えた

最初 “ぐるぐるがとまらない” とは一体どんな現象なのかわかりませんでした。

しかし、ネットワーク状況が悪い環境で起こりそうだと予想できたので、機内モードにしてアプリを起動してみると “ぐるぐる問題” が発生し、操作不能になることが再現できました。
デバッグモードで動作をたどっていくと、画面表示処理の途中で処理が止まっています。その箇所は通信処理で、ネットワーク状況が悪いとサーバーとの接続成功を待ち続けているようです。

結論として、”ぐるぐる問題” を解決するためには、タイムアウトで通信を失敗させれば良いとわかりました。 それをホリさんに伝えたところ、3点の要求がありました。

  • 1. ネットワーク状況が悪いことがエラーの理由だとユーザーに伝えてほしい
  • 2. ネットワーク状況が悪い状況下の動作を確認するテストを書いてほしい
  • 3. ネットワーク状況が悪くても通信成功確率が上がるよう実装を工夫してほしい

通信には Aramofire を使用していたので、通信処理をエラー終了させることはタイムアウトの設定で実現できそうでしたが、それをどうユーザーに通知すべきなのか、どうテストを書いていいのかがわかりませんでした。

そこでメンターのとみーさんに相談して、アプリのアーキテクチャを把握するところからはじめました。

BizHintのiOSアプリはSwiftで書かれていて Model -> ViewModel -> View という構造になっています。

Modelで LocalDBからデータの取得やAPI通信などを行い、ViewModelにはビジネスロジックが書かれています。ViewModel層ではViewのステートを管理していて、ステートに応じて画面の表示内容が変化します。

MVVM

1.ユーザーにエラーを通知する には以下の3STEPが必要だとわかりました。

  • Alamofire にタイムアウトを設定する
  • ViewModelが Modelから通信エラーを受け取ったらViewのステートをネットワーク不安定エラーにする
  • Viewはステートがネットワーク不安定エラーになったらエラーダイアログを表示する

2.エラー状態のテスト は、ViewModelに通信エラーが渡ったときにViewのステートが正しく変化することを確認するテストを書くことにして、次の2STEPを行います。

  • ViewModelがModelの実装に依存しないようにProtocol化し、テスト時はModelのモックから通信結果を渡すようにする
  • XCTestRxTestでテストを書く

テストをするためには、様々な種類の通信エラーを返すModelが必要ですが、ネットワーク状況を都合よく悪くすることが難しいため、Modelのモックを作ってエミュレートすることにしました。そこで、既存Modelの定義をProtocolにして、本番とテスト用のモック、2つの実装を用意します。

ViewModel-Model

3.通信成功確率の上昇 は、Model層の通信に RxSwiftretryWhen を挟むことで実現します。

1. ユーザーにエラーを通知する

1.1. Alamofireにタイムアウトを設定する

URLSessionConfiguration にタイムアウトを設定し SessionManager に渡しました。

let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 10 //タイムアウトする秒数
let manager = SessionManager(configuration: configuration)

1.2. エラーの通知処理を作る

まずは ViewModel がステートとしてエラーを持てるようにします。

enum ViewState {
    case blank // ブランク
    case requesting // リクエスト中
    case working // 通常
    case error(Error?) // エラー
}

enumの引数で原因となっているエラーのインスタンスを渡せるようにしました。 ViewModel はModelからエラーを受け取ったらステートを エラー に変更します。

1.3. Viewでエラーを通知する。

View はもともと ViewModel のステート変更によってUIを切り替えるようになっているので、 エラー に変更されたら View でエラー内容のアラートを出すことにしました。

将来アラートからエラー用のUIに切り替えることになっても、ステートを見てViewを変更しているだけなので、修正範囲は View 内で完結するはずです。

2. エラー状態をテストする

2.1. ViewModelが Modelの実装に依存しないように変更する

Model のプロトコルを定義をすることで依存度を下げます。 このようにすると、 ViewModelModelProtocol にしか依存しなくなり、コンストラクタで実装を差し替えることができます。

protocol ModelProtocol {
    func getData() -> Observable<Data>
}

class Model: ModelProtocol {
     func getData() -> Observable<Data> {
        // 実処理
    }
}

class ViewModel {
    let viewState = Variable(ViewState.blank)
    let model: ModelProtocol

    init(model: ModelProtocol = Model()) {
        self.model = model
    }

    func getList() {
        model.getData() // 取得したデータを処理
    }
}

2.2. 通信エラー処理のテストを作る

ViewModel 内で Model が通信エラーを返したときに正しくステートが変更されるかを XCTestRxTest でテストします。

まずは ModelProtocol に準拠する形で ModelMock を実装します。

テストのために、API通信で得られるデータの流れを RxTestTestableObservable で置き換えます。

ModelMock 内の getData はレスポンスからもらったデータを取得するメソッドです。

class ModelMock: ModelProtocol {
    let testResponse: TestableObservable<Data>

    init(testResponse: TestableObservable<Data>) {
        self.testResponse = testResponse
    }

    func getData() -> Observable<Data> {
        // testResponseからもらったデータを返す処理
    }
}

モックが完成したら ViewModel にモックを渡すユニットテストを書きます。

class ViewModelTest: XCTestCase {
    let disposeBag = DisposeBag()
    var scheduler: TestScheduler!
    var observer: TestableObserver<ViewState>!

    override func setUp() {
        super.setUp()
        scheduler = TestScheduler(initialClock: 0)
        observer = scheduler.createObserver(ViewState.self)
    }

    func testViewModelError() {
        let xs = scheduler.createHotObservable([
            error(100, 通信エラー, Data.self) // 時間100でエラーを流すObservableを作る。API通信の模擬。
            ])

        let model = ModelMock(testResponse: xs) // モデルのモックを作成する。
        let viewModel = ViewModel(model: model) // ViewModelにモックを渡す。

        scheduler.scheduleAt(50) { // 時間50でサブスクライブ。ステートはブランクで初期化される。
            viewModel.viewState.asObservable()
                .subscribe(self.observer)
                .disposed(by: self.disposeBag)
        }

        scheduler.scheduleAt(100) {
            viewModel.getList() // 時間100でデータを取得する。内部でModelMockのgetData()が呼ばれる。
        }

        scheduler.start()

        let expectedEvents = [
            next(50, ViewState.blank),
            next(100, ViewState.requesting),
            next(100 + 100, ViewState.error(通信エラー)),
            ]
        XCTAssertEqual(observer.events, expectedEvents)
    }
}

これで正常に通信がエラーになるユニットテストが書けました。

実際にはUIからのリトライ処理などが挟まって状態がもう少し複雑になるので、テストできると安心してリリースできます。

3. リトライ処理

通信の成功確率を上げてほしいという要求は、API通信部で RxSwiftretryWhen メソッドを呼ぶことで実現しました。

APIInvokeAlamofire による通信をWrapして Observable<Response> を返すメソッドだと思ってください。

APIInvoke(request)
    .retryWhen{ (errors: Observable<Error>) -> Observable<Int> in
        let maxAttemptCount = 3
        return errors.enumerated().flatMap{(retryCount, e) -> Observable<Int> in
            // リトライ上限に達してたらエラーを流す
            if retryCount >= maxAttemptCount {
                return Observable.error(e)                    
            }
            let retryWaitTime: RxTimeInterval = 3 //秒
            // このObservableが発行されるとリトライする
            return Observable<Int>.timer(retryWaitTime, scheduler: ConcurrentDispatchQueueScheduler(qos: DispatchQoS.userInitiated))
        }
}

retryWhen は少し分かりづらいですが、エラーが流れてきた場合にのみ反応するメソッドです。 retryWhen が引数でとっているクロージャは 流れてきたエラー -> リトライ条件 で、リトライ条件の Observable が発行されるとリトライを行います。

今回はネットワークのエラーが発生したら3秒ごとにリトライを試行し、3回失敗したらエラーをobserverに流しています。

学んだこと

ぼくは学生時代に研究用のスクリプトを書く程度で、アプリケーション開発やチーム開発は未経験でした。

そのため、アーキテクチャや疎結合、可読性など言葉だけは知っていたものの、それらが開発時にどんな体験をもたらすのかいまいちピンと来ておらず、本の中で説かれていることの価値を本質まで理解できていないという感覚をずっと持っていました。

BizHintのインターンでは、課題の特定、アーキテクチャの理解、機能の実装、テストの実装といった一連の開発を任せてもらいました。

そして、経験を通して、このようなことを学びました。

  • コードを読んで理解することの重要性
  • 複数人で開発するときには可読性の高いコードをかく必要があること
  • レビューは、コードの品質を担保する役割であること

コード量が多くなっても、アーキテクチャがしっかりしていると、どこに何を書けばいいのかすぐにわかるというのも非常に大きな学びでした。

疎結合にすることで、変更しやすくなったり、コードを書いているときに余計なことを考えなくて済むといったありがたみも体験できました。

文中にでてるひとより

新宿駅では弱々しいWi-Fi拾っちゃうんです。特に山手線ホームで。
昨今、通信環境は強く安定的なので意識する機会は少ないですが、堅牢な通信処理を実現できてこそアプリエンジニア、との思いでタマセンに頑張ってもらいました。 〜 ホリ

インターンを始めたときはアプリ未経験でしたが、インターン終盤ではアプリ開発のすべてを任せられるようなりました。配属後の活躍が楽しみです。 ちなみに五反田駅は空いているため気が付きませんでした。渋谷勤務で山手線沿線に住むなら渋谷~五反田間が空いていておすすめです。 〜 冨永