Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

フロントエンドのCircle CI実行時間を1/3にしました

こんにちは。ForSchool事業部の石上です。先日、コンビニでサクレの梨味を買ってきたところ妻に絶賛されました。今年一番家庭で貢献した日かもしれません。

今回はフロントエンドのCircle CI実行時間を短くしたことについて書きます。

3行で

  • フロントエンドのCI実行時間が長く困っていました
  • 無駄なものを消したり並列化したことで、実行時間が9分(改善前)から3分(改善後)になりました
  • 今回サクッとできたのは改善がしやすい仕組みのおかげ

Studyplus for School フロントエンドのテスト

私が現在携わっているStudyplus for School(塾向けのSaaS)のフロントエンドでどのようにテストを書いているかについては、以前@okuparaさんが書いてくれました。

tech.studyplus.co.jp

この方針が定まる前はテストコードが少なかったのですが、方針を定めて開発メンバー全員で少しずつ書いていった結果、現在はだいぶテストコードが増えました。

Jestの実行結果に出力されるテスト数。Test Suites: 77, Tests: 403, Snapshots: 4 passed

テストコードが増えたため、実行時間も長くなりました。今回の改善をする前には、CIの実行時間がおよそ9分かかっていました。1ページに対するテスト実行時間は複雑な仕様のページだと1分を超えます。このままいくと、既存画面へのテストコード追加や新規画面の追加によって、10分、11分...と増えていくことは明らかでした。

CIの実行はGitHubへのpushの度に行われ、リリースの際は必ずCI上のテストが通ってから行うことになっています。CIに10分かかるということはリリース作業の度に、10分も手が止まってしまうことになります。

Circle CIの実行時間は9分ほどだった

やったこと

それに対してやったことは以下の2つです。

  • 無駄なものを消す
  • 並列化する

無駄なものを消す

CIで実行する必要があるものを見直しました。改善前は以下のような設定になっていました。

Circle CIの実行画面。tsc, prettier, eslint, jest, build-storybook, post test coverage

この中で、Storybookのビルドは不要と判断しました。というのも、ビルドサーバーは別にあり(Jenkins)、Storybookのビルドはそちらでも行っていたからです。

また、Jestでカバレッジの出力をしていましたが、これもCI上では行わないようにしました。実行時間がそこそこかかるのと、この次に行うテストの並列実行の設定も難しそうだったからです。テストが全然書かれていない状態から増やしていくときには良いモチベーションになっていましたが、現在テストカバレッジは70%を超えています。実行時間を長くしたり設定を複雑にしてまで、この値を毎回確認する必要もないだろうという判断です。カバレッジを確認したいときはローカルで実行すれば確認できます。

並列化する

Circle CIジョブの実行と、テストを分割して並列実行する設定をしました。これはCircle CIのドキュメント通りです。

workflows:
  build:
    jobs:
      - build
      - lint:
          requires:
            - build
      - tsc:
          requires:
            - build
      - test:
          requires:
            - build

設定後、以下のようになりました。

build でnode_modulesのインストールなどを行い、ワークスペースを作ります。その後の各ジョブはそのワークスペースでタスクを実行しますので、かかる時間はほぼその個別のコマンドの時間のみとなります。

workflowの分割と並列実行

テスト分割は、Circle CIのコマンドを利用してファイル名を分割して、各コンテナでテストランナーに渡します。並列数は、今回は8にしました。ざっくりと2,4,8,16と増やして試して16で速くならなかったので8に留めました。ここはもう少し最適化の余地がありそうです。また、Circle CIではtiming dataというテスト実行時間の記録を使って最適に分割する機能もあります。今回はこれも使わず、単純にファイル数での分割にしました。今後さらに速くしたいとなれば、その辺の設定も検討したいと思います。

- run:
    name: test
    command: circleci tests glob 'src/**/*.test.{ts,tsx}' 'tests/**/*.test.{ts,tsx}' | circleci tests split | xargs yarn test

テストの分割と並列実行

これで、ジョブを並列にしつつ、テストも分割して並列実行できるようになりました。実行時間は3分ほどなので、修正前のおよそ1/3となりました。

並列になって速くなった様子(CircleCIの画面)。実行時間は2m59s。

今回サクッとできたのは改善がしやすい仕組みのおかげ

このような改善は、それ自体が難しくなくても後回しになりがちです。私のこれまでの経験でも、こういった作業は特に計画に入れず手が空いたときにやるということが多かったです。

Studyplus for Schoolの開発チームでは、毎スプリント2割はこういった技術的改善に当てて良いということになっています。そのため事業に直接影響がない(けど開発効率に地味に影響がある)ような改善を、エンジニアが優先度を決めてできるようになっています。この仕組みを利用して、今後も開発効率をじわじわと上げていけたら良いなと考えています。

最後に

と、自チームを持ち上げた後で恐縮ですが、現在弊社のForSchool事業部(Studyplus for Schoolをつくっている部署です)ではウェブアプリケーションエンジニアを募集しています!ご興味のある方はどしどしご応募ください。

カジュアル面談はこちら open.talentio.com

面接はこちら open.talentio.com

Studyplus iOSにおけるコードリファクタリングへの取り組み

Studyplus iOSにおけるコードリファクタリングへの取り組み

こんにちは、Studyplus事業部モバイルクライアントグループの上原です。以前はWidgetの実装についてブログを書きましたが、今回はStudyplus iOSで取り組んでいるコードのリファクタリングについて書きます。

tech.studyplus.co.jp

モバイルクライアントグループでは、コードのリファクタリングを積極的に進めており、直近では下記を並行して作業しています。

  • Objective-CからSwiftへの移行
  • Codable対応
  • 通信処理の改善
  • 画面遷移処理のCoordinatorパターン準拠対応

Objective-CからSwiftへの移行

Manual Layoutで書かれた部分をAuto Layoutに修正

Objective-Cで書かれたコードは、作られた当時のままManual Layoutでframe計算をしていました。 Swiftへの移行の際に、Viewも新たに定義しAuto Layoutでのレイアウトの変更をしました。 これをしたことにより、今後のViewの変更がやりやすくなったのと不要な高さの計算メソッドなどが無くなりコードの可読性が向上しました。

テストの追加

移行の際に既存コードと同じ動作をしているかどうかを検証するためにテストコードを追加しました。 テストコードを書くことによって、元の動作が何をしているかを把握しやすくなります。 同時に、そのメソッドが実際に動作しているかを確かめることができるようになります。 変更の際に壊れたかどうかが分かるようになり、開発効率が良くなりました。

最近、上記が完了し、Swift100%でコードが書けるようになり開発がよりしやすくなりました。

Codable対応

既存レスポンスとリクエストで辞書型をパースしたり辞書型を渡していたものをクラスや構造体に変更

参考として、レスポンス解析の新旧コードについてサンプルを記載します。

final class SampleResponse {

    private(set) var sampleString: String?

    init(attributes: [AnyHashable: Any]) {
        sampleString = attributes["sample_string"] as? String
    }
}

上記のようにinitializeに辞書型を渡して辞書型からパースしていた処理を、Decodableを利用して簡潔に書くことができるようになりました。

final class SampleResponse: Decodable {
    private let sampleString: String?
}

上記の例では、sampleStringでキャメルケースになっていますが、APIから受け取っているレスポンスのキー名はスネークケースになっています。 デコード時に以下のように指定することで、キャメルケースのプロパティ名でもCordingKeysを都度定義しなくて済んでいます。

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

通信処理の改善

API呼び出し部分での成功や失敗の際のクロージャをResult型を渡すクロージャに修正

func postSampleAPI(sampleId: Int, success: @escaping (SampleResponse) -> Void, failure: @escaping (SampleError) -> Void, finally: @escaping () -> Void)

success, failure, finallyで3つ用意されていたクロージャをcompletionでResult型を返すクロージャに変更しました。 この変更によって内部が複雑化した時にクロージャの呼び忘れを減らせたのと、呼び出す必要のなかったクロージャを呼び出さなくて済むようになりコードの見通しが良くなりました。

func postSampleAPI(sampleId: Int, completion: @escaping (Result<SampleResponse, SampleError>) -> Void)

画面遷移処理のCoordinatorパターン準拠対応

前回のブログで触れられていたCoordinator Patternの導入について紹介します。

tech.studyplus.co.jp

画面間の遷移の処理をCoordinatorに任せ画面遷移のロジックをCoordinatorだけで管理する

対応していて感じたメリットは以下のとおりです。

  • 肥大しがちなViewControllerから画面遷移のロジックを移すことができ、ViewControllerの肥大化が抑えられる
  • 本来渡す必要のなかったオブジェクトから必要なデータのみを渡す実装にしていける

実装について簡単に説明します。 Coordinatorプロトコルを定義し各Coordinatorクラスで準拠させます。 下記の例では、 SampleCoordinatorSampleViewController の画面遷移の責務を持っています。

画面遷移を行う場合は SampleCoordinatorstart を呼び出し、 SampleViewController を生成し表示させます。 SampleViewController で画面遷移を行う時には delegatetransitionSampleDetail を呼びます。 この呼び出しにより、 SampleCoordinator で遷移処理が行われます。

この実装によって SampleViewController は画面遷移の責務を持たなくなります。 コードの肥大化が抑えられ、画面遷移は Coordinator を見れば分かるといった点で、分かりやすくできました。

protocol Coordinator {
    func start()
}

final class SampleCoordinator: Coordinator {
    private let navigator: UINavigationController
    private let sampleViewController: SampleViewController?
    
    init(navigator: UINavigationController) {
        self.navigator = navigator
    }
    
    func start() {
        let sampleViewController = SampleViewController(delegate: self)
        navigator.pushViewController(sampleViewController, animated: true)
        self.sampleViewController = sampleViewController
    }
}

extension SampleCoordinator: SampleViewControllerCoordinatorDelegate {
    func transitionSampleDetail() {
        let viewController = SampleDetailViewController()
        navigator.pushViewController(viewController, animated: true)
    }
}

protocol SampleViewControllerCoordinatorDelegate: AnyObject {
    func transitionSampleDetail()
}

final class SampleViewController {
    private weak var delegate: SampleViewControllerCoordinatorDelegate?
    
    init(delegate: SampleViewControllerCoordinatorDelegate) {
        self.delegate = delegate
    }
    
    private func transitionDetail() {
        delegate?.transitionSampleDetail()
    }
}

リファクタリングの進め方

上記の各リファクタリングは規模が大きく、変更する点が多岐に渡ります。 このリファクタリングを進めていくにあたってどのように作業しているかと言うと、例えばCoordinator準拠対応については以下の通りです。

  1. GitHubのProjectsへリファクタリングのテーマでボードを作成
  2. 対象は画面のため、アプリプロジェクト内のファイル名末尾ViewController.swiftで検索
  3. 検索結果のクラスを1件ずつ、ToDoのカラムへチケットとして起票
  4. 該当クラスについて作業する際に、作業中のカラムへチケットを移動
  5. 作業が終わったらPRを作成しコードレビューに出し、コードレビューの欄にチケットを移動

上記の作業をするようにしたおかげで、作業が被らず、進捗がすぐに確認できるようになりました。 また、たくさんあったチケットが減っていくのも達成感があり良かったです。

さいごに

モバイルクライアントグループで取り組んでいるリファクタリングについて書かせていただきました。 このリファクタリングを進めていくことで、コードの可読性やコードの削減などが見込め開発効率が上がっていくことが想定されるので頑張っていきます!

現在、弊社ではモバイルアプリエンジニアの募集はしていませんが、カジュアル面談は受付中です。 気になった方は、お気軽にお申し込みください。

https://open.talentio.com/1/c/studyplus_recruit/requisitions/detail/20046

Paging 3ライブラリへの更新とKotlin 100%

こんにちは。 AndroidとiOSといろいろやっている若宮(id:D_R_1009)です。

西日本では記録的な早い梅雨入りな一方、会社のある関東ではだいぶ遅れての梅雨入りとなりました。 部屋にこもってAppleやGoogle、Microsoftのカンファレンスを見ることで時間は潰せるのですが、ちょっと英語に圧倒されつつあります。


さて、Studyplus Androidアプリでは5月17日リリースの v7.0.10 においてPaging 3に更新したアプリをリリースしました。 リリースから1月以上経ったので、Paging 3の紹介と知見を書きます。

Pagingライブラリとは

Pagingライブラリは大量のデータを扱う際に、メモリ使用量の管理や追加読み込み処理を手助けしてくれるライブラリです。 RecyclerViewと相性が良く、タイムラインのようなリストで力を発揮します。

developer.android.com

2021年5月にv3がリリースされました。

v2はJavaでしたが、v3から全てKotlinで書かれています。 このためAndroid Jetpackでは珍しく、 -ktx ライブラリがありません。

developer.android.com

StudyplusアプリとPagingライブラリ

tech.studyplus.co.jp

Studyplusアプリでは、バージョン v4.14.3 にてPagingライブラリ v1.0.1 を導入し始めました。 v4.14.3 は2018年11月のリリースとなるため、既に2年半以上利用していることになります。 導入後はタイムラインだけではなく、ページネーションされているAPIに利用しているため、現時点では10を超える箇所で利用するようになっています。

ページネーションされたAPIの場合、リストの終端を表示したイベントをキーにして、データの追加読み込みを行わないといけません。 構造上、この処理はUIのスクロールイベントをキーとして、APIリクエストを行う必要があります。 Pagingライブラリを導入することで、「リストの終端判定」をライブラリが適切に隠蔽かつ処理するようになります。 UIとバックグランド処理を適切に隠蔽してくれるため、導入すればするほど利用したくなるライブラリです。

Paging 2からPaging 3への更新

先ほども紹介しましたが、2021年の5月にPagingライブラリのv3がリリースされました。 v2の最終リリースは2020年3月なので、1年ぶりの大更新です。

変わることと変わらないこと

v3の大きな特徴の1つに、Kotlin Coroutinesの採用があります。

developer.android.com

class ExamplePagingSource(
  val backend: ExampleBackendService,
  val query: String,
) : PagingSource<Int, User>() {
  override suspend fun load(
    params: LoadParams<Int>,
  ): LoadResult<Int, User> {
    try {
      // Start refresh at page 1 if undefined.
      val nextPageNumber = params.key ?: 1
      val response = backend.searchUsers(query, nextPageNumber)
      return LoadResult.Page(
        data = response.users,
        prevKey = null, // Only paging forward.
        nextKey = response.nextPageNumber,
      )
    } catch (e: Exception) {
      // Handle errors in this block and return LoadResult.Error if it is an
      // expected error (such as a network failure).
    }
  }
~~
}

suspend fun load であるため、このメソッドの中からは suspend を付けた関数を呼び出すことができます。 このため、RetrofitやApolloを利用している場合、APIへのリクエストをCoroutinesで書きやすくなりました。 ライブラリの中を追っていくと、処理しているのは(デフォルトでは) Dispatchers.IO のため、スイッチングコストも気になりませんね。

github.com

github.com


一方で、View側には影響が出ないように工夫されています。

developer.android.com

androidx.paging.PagedListAdapterandroidx.paging.PagingDataAdapter に、import元を更新すればほぼ問題ありません。 変更が必要なのは、Adapterに対してデータを追加するActivityやFragment、そしてViewModelです。

LiveDataで更新する

Pagingライブラリから取得されるデータは、Flowになります。 このFlowをViewModelでLiveDataに変更すると、UI層の処理はほぼ変更がありません。

例えば、次のように TimelineRepository から任意のタイムラインを取得するケースがあるとします。 feed() にて Flow<List<Feed>> を取得できるとします。 この時、 asLiveData() を利用すると、下記のような処理になります。

class TimelineViewModel @Inject constructor(
  repository: TimelineRepository,
): ViewModel() {
  val timeline: LiveData<PagingData<Feed>> = repository.feed().cachedIn(viewModelScope).asLiveData()
}
class TimelineFragment: DaggerFragment(R.layout.timeline) {

  @Inject
  lateinit var factory: ViewModelFactory<TimelineViewModel>
  private val viewModel by viewModels<TimelineViewModel> { factory }

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    val binding = FragmentTimelineBinding.bind(view)

    val adapter = TimelineAdapter()
    binding.recyclerView.adapter = adapter

    viewModel.timeline.observe(viewLifecycleOwner) {
      // lifecycleが引数になるため、Fragmentの場合はviewLifecycleOwnerのlifecycle
      adapter.submitData(viewLifecycleOwner.lifecycle, it)
    }
  }
}

submitDataLifecycle を引数にする箇所は増えますが、それ以外はほぼ変更なく移行できます。 この対応にするとLiveDataが残るものの、コード上の差分を抑えてチェックできます。

Flowで更新する

次に、ViewModelでLiveDataに変換しないケースです。

class TimelineViewModel @Inject constructor(
  repository: TimelineRepository,
): ViewModel() {
  val timeline: Flow<PagingData<Feed>> = repository.feed().cachedIn(viewModelScope)
}
class TimelineFragment: DaggerFragment(R.layout.timeline) {

  @Inject
  lateinit var factory: ViewModelFactory<TimelineViewModel>
  private val viewModel by viewModels<TimelineViewModel> { factory }

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    val binding = FragmentTimelineBinding.bind(view)

    val adapter = TimelineAdapter()
    binding.recyclerView.adapter = adapter

    // Fragmentで呼び出す場合はviewLifecycleOwnerを利用する
    viewLifecycleOwner.lifecycleScope.launch {
      viewModel.timeline.collectLatest { pagingData ->
        adapter.submitData(pagingData)
      }
    }
  }
}

Fragmentの中で viewLifecycleOwner.lifecycleScope.launch を呼び出している以外は、LiveDataの実装と変わりません。 ActivityやFragmentの中でKotlin Coroutinesをどのように利用するか、既に議論が済んでいるチームであれば、採用できますね。

Room DBを利用している場合

v2において PagedList.BoundaryCallback を利用している場合は、変更点が大きくなります。 実際にマイグレーションを行った際、気になったのは下記3点です。

  1. DB操作時に利用するExecutorを明示的に指定しなくて良くなる
  2. 実装量が大幅に削減される
  3. ExperimentalPagingApi アノテーションが必要になる

1はKotlin Coroutinesを利用した恩恵です。 このため、トランザクション処理を記述する際に複数のメソッドを余計に定義しなければならない状況を解決できます。 また、Kotlin Coroutines上で処理が完結するため、処理が高速になることが期待されます。

実装は PagedList.BoundaryCallback で分割されていたメソッドが RemoteMediator では1つにまとめられました。 初回のデータ読み込みとリストの最初に追加する読み込み、最後に追加する読み込みを最低限の分岐だけで記述できるようになります。

developer.android.com

developer.android.com

2021年6月のサンプルコードを一部省略の上で抜粋してみます。 LoadType により処理を分岐することで、シンプルに処理をまとめているのがわかりますね。

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService,
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()
  val remoteKeyDao = database.remoteKeyDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>,
  ): MediatorResult {
    return try {
      val loadKey = when (loadType) {
        LoadType.REFRESH -> null
        LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
        // Query remoteKeyDao for the next RemoteKey.
        LoadType.APPEND -> {
          val remoteKey = database.withTransaction {
            remoteKeyDao.remoteKeyByQuery(query)
          }

          if (remoteKey.nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = true)
          }

          remoteKey.nextKey
        }
      }

      // Suspending network load via Retrofit. This doesn't need to be wrapped in a
      // withContext(Dispatcher.IO) { ... } block since Retrofit's Coroutine CallAdapter
      // dispatches on a worker thread.
      val response = networkService.searchUsers(query, loadKey)

      // Store loaded data, and next key in transaction, so that they're always consistent
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          remoteKeyDao.deleteByQuery(query)
          userDao.deleteByQuery(query)
        }

        // Update RemoteKey for this query.
        remoteKeyDao.insertOrReplace(RemoteKey(query, response.nextKey))

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.users)
      }

      MediatorResult.Success(endOfPaginationReached = response.nextKey == null)
    } catch (e: IOException) {
        MediatorResult.Error(e)
    } catch (e: HttpException) {
        MediatorResult.Error(e)
    }
  }
}

おわりに

Paging 3に更新したことで、プロダクトとして嬉しかったことをまとめます。

Kotlin 100%

Paging 3ライブラリに更新することで PagingRequestHelper クラスを削除できます。

github.com

PagingRequestHelperPagedList.BoundaryCallback のヘルパークラスです。

// THIS class is likely to be moved into the library in a future release. Feel free to copy it
// from this sample.

と記載されていたように、Paging 2で PagedList.BoundaryCallback の利用を助けるクラスです。 ご覧の通りJavaで書かれており、それなりに複雑な処理を行っているため、Kotlinに書き換えることが難しい状態にありました。

Studyplus Androidアプリでは、2020年6月ごろにKotlin化をほぼ完了し、残りは PagingRequestHelper だけとなっていました。

tech.studyplus.co.jp

上記ブログのように2019年9月末に86%程度だったKotlin率は、2020年6月頭に99%となり、その後はKotlinのみ増減している状態になります。 1ファイルのみ、Paging 2のutilクラスをJavaで利用しているので、Paging 3のリリースと同時に100%となる見込みです。 リリースが待ち遠しい……!

今回、Paging 3がリリースされたことにより、満を持してアプリのコードからJavaファイルを削除し切ることができました。 2018年の3月からKotlinへの置き換えを始めていたので、まる3年かかった計算になります。 Paging 3がKotlinで書かれたことで、導入するアプリ側もKotlinにしやすくなっています。

Jetpack Compose

developer.android.com

Paging 3にはJetpack Compose用の拡張が用意されています。 Pagingライブラリを利用するような大量のデータを表示する仕組みと、宣言的なUIは、待ち望んでいた組み合わせと言えます。

またPagingライブラリを利用すると、Repository層からUIに向けてデータが一方的に流れてくる仕組みとなります。 このためPagingライブラリを利用しておくと、Composeを導入する前からUIとデータの更新処理を分離しておくことができます。

既存のViewの仕組みをそのままComposeに移行できるので、移行の予定があってもPagingライブラリに対して懸念はありません。

developer.android.com

サンプルコードも既に用意されているため、機会があれば導入する予定です。

まとめ

Paging 3ライブラリは、個人的に強く推奨しているJetpackのライブラリです。 これまではUIとロジックの分離に、これからはComposeへのデータソースとして。 アプリのアーキテクチャに合わせて、色々なところで活躍してくれるでしょう。 これからもバンバン使っていきたいですね!


スタディプラスでは、下記のページにて一緒にアプリケーションを作り上げてくれるエンジニアを募集中です。

open.talentio.com

現時点ではモバイルクライアントグループで募集がオープンにはなっていませんが、もしもご興味がある場合はお気軽にカジュアル面談をお申し込みください!

アプリ画面のライフサイクル管理をAppDelegateからSceneDelegateへ移行した話

こんにちは、Studyplus事業部モバイルクライアントグループの明渡です。 最近、当ブログの当番がチーム単位から個人単位へ変更になりました。 ひとまずブログ執筆をご無沙汰していた順に回るのですが、自分は昨年iOSDCのLT登壇内容まとめ記事以来でした。

iOS 13におけるSiri Shortcuts 最小実装+α スライド書き起こしと補足 - Studyplus Engineering Blog

今年も最近リリースした機能をネタにプロポーザルを出してみたので、もし通ったらいろいろ頑張ります。

2021年5月、Studyplus iOS版アプリにて画面のライフサイクル管理をするクラスを移行しました。 具体的には、UIApplicationDelegate準拠のAppDelegateから、UIWindowSceneDelegate準拠のSceneDelegateへ乗り換えました。

Studyplusでは今年3月にiOS 12のサポートを終了しました。 これにより、iOS 12向けの分岐を混在させる必要がなくなり、綺麗に移行できる準備が整ったので、一気に対応してしまいました。

今回は、移行をスムーズにするため行なった事前リファクタリングと、StudyplusにてAppDelegateからSceneDelegateへ移行した処理について紹介します。

AppDelegateとSceneDelegateとは

iOSアプリ開発でおなじみ、プロジェクト作成時に1アプリにつき1クラスずつテンプレートが生成されるアプリのライフサイクルを管理するクラスです。

iOS 12までのプロジェクトはAppDelegateのみ、iOS 13以降およびiPad OS 13以降のアプリではAppDelegateとSceneDelegateが共に作成されます。

SceneDelegateは、1アプリを複数ウィンドウで操作を可能とするMultiple Windows対応に必須です。 Studyplusでは現状、Multiple Windows対応の肝である複数画面での操作受付(Info.plistにてSupports multiple windowsの値)はOFFにしています。 実際に複数画面での操作に対応するにはどの画面の情報をどのように保持するか精査する必要があるためで、近い将来きちんと対応したいところです。

事前リファクタリング

SceneDelegate移行の前に、リファクタリングとしてCoordinatorパターンを導入しました。

speakerdeck.com

導入にあたり、スライド、および書籍のiOSアプリ設計パターン入門を参考にしました。 一番の目的としては、長年処理を継ぎ足し続けて混沌としていたAppDelegateから画面遷移処理を剥がし、安全にSceneDelegateへの移行をするためです。

今回適用したのはAppDeleagteにて生成していた画面のみですが、今後作成する新機能および既存の他画面も準拠させる予定です。 GitHubのProjectsへボードを作り、アプリプロジェクト内のファイル名末尾ViewController.swiftで検索をかけて全部チケットを起票しておきました。

なお、こうして起票したチケットは、各エンジニアが手が空いた時に着手中(In progress)にステータスを切り替えて対応を進めているため、対応するクラスが重複する心配がありません。 また、1つのテーマに関するチケット群がどの程度対応完了したかの進捗が分かりやすくなるので、対象が膨大なリファクタリング等を進める際に大変重宝しています。

現状のStudyplusでは向こう数年UIKitベースがメインになることが見えていたので迷わずCoordinatorパターンを導入しました。 しかしながら、今後SwiftUI化を積極的に進めていく場合には課題がある設計なので、導入は慎重に検討することをお勧めします。

AppDelegateからSceneDelegateへ移行した処理

アプリの画面生成・遷移処理

SceneDelegateは画面(UIWindow)のライフサイクルを管理するクラスのため、画面生成および遷移を伴う処理は軒並み移行しました。

具体的には以下の通りです。

  • application(_:didFinishLaunchingWithOptions:)内の画面生成処理
    • 通常のフローでアプリ起動時に初期生成する画面
  • application(_:continue:restorationHandler:)内の画面生成・遷移処理
    • Universal Links、Siriショートカットを経由してアプリが起動された際に生成・遷移する画面
  • application(_:open:options:)内の画面生成・遷移処理
    • カスタムURLスキーマを経由してアプリが起動された際に生成・遷移する画面
  • application(_:didReceiveRemoteNotification:fetchCompletionHandler:)内の画面生成・遷移処理
    • プッシュ通知を押下してアプリが起動された際に生成・遷移する画面

Studyplusでは、諸事情により通常のフロー以外でアプリが起動された際、すでに起動済みだった場合は前の画面は保ったまま画面遷移しています。

AppDelegateでは通常フロー以外の起動時に特定のメソッドを同じ順番で必ず通る保証がないため、同じメソッド内に起動時と起動済みの分岐をまとめて定義が必要でした。

SceneDelegateでは通常フロー以外のアプリ起動時にも必ずscene(_:willConnectTo:options:)を通るようになりました。 これにより、アプリ起動時or起動済み時の判定をscene(_:willConnectTo:options:)から呼び出されたかそうでないかで綺麗に分けられるようになりました。

Studyplusではプッシュ通知に関する処理をまとめて定義している箇所に影響があり、それぞれのケースで必要な処理を綺麗に分離できて嬉しかったです。

画面が生成、破棄された都度必要な処理

それぞれ、以下のタイミングで呼ばれていた処理はほぼ丸ごと移行しました。 すべてApple Developerのドキュメントへ移行を推奨する旨が記載されているメソッドです。

  • applicationWillEnterForeground(_:)sceneWillEnterForeground(_:)
  • applicationDidBecomeActive(_:)sceneDidBecomeActive(_:)
  • applicationWillResignActive(_:)sceneWillResignActive(_:)
  • applicationDidEnterBackground(_:)sceneDidEnterBackground(_:)

おまけ:AppDelegateからSceneDelegateへ移行しなかった処理

アプリのプロセス(UIApplication)のライフサイクル毎に行う処理はAppDelegateから移動させませんでした。

具体的には以下の通りです。

  • application(_:didFinishLaunchingWithOptions:)内のアプリ起動時1回のみ呼ぶ必要がある処理
    • 課金トランザクションの検知準備や、Firebaseなど利用しているライブラリの初期化処理など
  • applicationWillTerminate(_:)内のアプリ終了時に処理
    • 課金トランザクションの管理終了や、次回アプリ起動時に保ちたいデータの保存など

さいごに

以上、Studyplus iOS版におけるSceneDelegate移行対応のお話でした。

事前準備のほうに数倍時間がかかったので、普段からAppDelegateが綺麗に整理されていればあっという間に移行できるでしょう。

幸い、移行による目立ったデグレは現在まで発生しておらず、つつがなく完了できたといえます。 Coordinatorパターン適用前のAppDelegateでは、そのまま移行すると何かかしらデグレさせてしまいそうで怖かったです。

Coordinatorパターンはこれから全画面で準拠していくのが目標なので、進むにつれ画面遷移処理が疎結合になって見やすくなり、テストしやすくなっていくのが楽しみです!

現在、弊社ではモバイルアプリエンジニアの募集はしていませんが、カジュアル面談は受付中です。 それぞれ主担当のiOS・Androidに閉じず、Portoで採用しているFlutterを触る機会も増えてきて、個人的には日々新鮮で楽しく過ごしております。興味のある方はお気軽にお申し込みください!

open.talentio.com

Firebase Functionsのロギングを改善した話

はじめまして、モバイルクライアントグループの市川です。昨年9月からポルトの開発にジョインしました!

porto-book.jp

ポルトはFlutterとFirebaseで開発しているサービスです。 サーバサイドの処理は全てFirebase Functionsで実装されており、エンドポイントの数は40近くあります。 その中には、課金に関するクリティカルなAPIや外部サービスと連携するAPIなど、問題が起きるとサービスの継続に大きな影響を与えるものも多くあります。 今回はサービスを安定運用するため、Firebase Functionsのロギング改善した話を3つ書こうと思います。

ロガーの変更 と エラーレポーティング

ポルトのFunctionsのロギングは、consoleを利用していました。現状では、ロガーSDK loggerが公開されており、そちらが推奨されているので、すべてのロギングをロガーSDKに変更しました。 FunctionsのロギングはCloud Loggingと統合されていますが、ロガーSDKを利用することで、より適切なログの管理できます。特に便利だと感じた点は以下の2つです。

①重大度レベルの反映

  • consoleの場合、console.errorでロギングしてもCloud Logging上では重大度レベルが反映されません。なのでエラーログもデバックログと同等に扱われてしまい、致命的な問題が起きているかを把握することが難しい状況でした。ロガーSDKを使うと適切に重大度レベルが設定されます。
  • さらにCloud LoggingはError Reportingと連携しており、重大度レベルがエラーのログは自動的にレポーティングされます。デフォルトでメールにも通知してくれるので、リアルタイムで問題に気がつくことができました。Sentryなどを導入しなくてもGCPだけでエラーレポーティングできるのはかなり便利です。

②ロガーの引数が柔軟

  • consoleの場合、Objectを渡すと、ログが複数に分割されたり、意図しないログ出力になってしまうため、渡す値を文字列にするなど工夫が必要でした。
  • ロガーSDKの場合、Objectを渡しても、自動で文字列に展開してくれます。また最後の引数に渡したObjectはログエントリのjsonPayloadとしてjson形式で保存してくれる機能もあります。
    • なお、jsonPayload内の値はCloud Loggingで検索可能なので、各Functionsの最初でリクエストパラメータやユーザ情報(UID)をしまっておくと、あるユーザが実行したAPIをトレースできるようになるのでオススメです。

参考情報

ロガーSDKやエラーレポーティングについては公式ドキュメントに日本語で書かれていますので、とても参考になります。

ログの保持期間の変更

Functionsのログはデフォルトでは30日しか保存されません。30日だと月単位で実行される処理において、前回の状況を確認できず困ったことがありました。 ただ、ログの保持期間を長くしすぎるとその分だけ費用がかかってしまうので、ポルトでは一旦100日に変更しました。

Functionsのログは、Cloud Loggingのログバケット_Defaultに保存されますので、その保持期間を変更することで対応できます。

f:id:popobot:20210531151723p:plain

不要ログの排除

ポルトではFirestoreの重要なコレクションについては、変更履歴を確認できるように、Firebase ExtensionsのExport Collections to BigQueryを利用してFirestoreの更新をBigQueryにエクスポートしています。この拡張機能はFunctionsを追加する形で実装されていますが、実行時に勝手にログを書き込みます。Firestoreに変更があるたびに実行されるので、ログ量はかなり多くなっていました。 不要なログが増えてると、Cloud Loggingのコンソール画面で閲覧するとき邪魔ですし、費用も余計にかかってしまいます。

そこでCloud Loggingのログルーター機能を利用して、Firebase Extensionsのロギングを除外しました。

除外の設定方法

  • Cloud Loggingのメニューから「ログルーター」を選び、シンク_Defaultの編集画面を開きます
  • 「シンクに含めないログの選択」から「除外設定を追加」を選んでフィルタを作成します
  • フィルタを設定します
    • 除外フィルタ名: ext-firestore-bigquery-export (これはなんでもよい)
    • 除外フィルタ率: 100
    • 除外フィルタ: 以下の条件式
resource.type="cloud_function"
resource.labels.function_name=~"^ext-firestore-bigquery-export"

f:id:popobot:20210531151657p:plain

まとめ

今回はポルトのFunctionsのログ管理について紹介しました。ログを適切に管理することで、大きく2つの安心を得ることができました。

  • エラーレポーティングによって、問題があったらすぐに気がつける安心
  • トラブルが発生した際に、問題を切り分けるための情報がある安心

また、これらの機能をGCPだけで簡単に実現できるのはFirebaseの強みだと感じました!

入社して1年で見えてきた弊社のすてきなところ

こんにちは、Studyplus事業部サーバーグループの葉坂です。スタディプラスに入社して約1年が経ちました。

そこで、本記事では入社して1年経って見えてきた私の感じる弊社のすてきなところを紹介していきます。タイトルからして会社に忖度している胡散臭い記事に見えるかもしれませんが、すべて事実なのでご安心ください。

特に現在、転職活動中の方の参考になればと思います。

入社時に希望のスペックのPCを貸与してもらえる

35万円(税抜き)までを目安として希望のスペックのPCを貸与してもらえます。この予算であれば、ほとんどのPCを網羅できているはずです。ちなみに2年ごとに買い替えることもできます。

※予算に関しては入社時期によっては多少前後するかもしれません。

柔軟性の高い働き方ができる

フルフレックスとフルリモートを導入しているので、各々の生活に合わせて自由に働くことができます。もちろんコアタイムなしです。また、入社時からずっとフルリモートのメンバーもいたりします(私もそうです)。

ちなみにオンボーディングの際は、ZoomやSlack Callを繋ぎっぱなしにし、いつでも質問しやすい環境を作ってもらいました。 また、Slackに疑問点を投稿しておくと、メンバーが拾って答えてくれるので、リモートワーク中心の働き方でも特に困ることはありません。

社内勉強会が豊富

※下記はすべて業務時間内で枠を確保し開催しています。

Kubernetes輪講会の開催

以前私が書いた「Kubernetes輪講会を開催しました」でも紹介しているので詳細は省きますが、Kubernetesの本番導入に伴いサーバーチームもKubernetesの運用上必要な知見を高められるようSREチームと合同でKubernetes輪講会を開催しました。

tech.studyplus.co.jp

Golang輪講会の開催

サーバーチームが担当している10あるマイクロサービスのうち1つがGolangで書かれていることや、今後新たなマイクロサービス作成の際の言語の選択肢を増やすため、「Goプログラミング実践入門」を用いて輪講会を開催しました。

book.impress.co.jp

ちなみに

現在はGolang輪講会の開催枠で、Golangを使って各々が作りたいものを実装するモクモク会を開催しています。

個人主催の勉強会・共有会の開催

今後取り入れていきたい技術について勉強会を開催したり、新しく導入した技術・仕組みについての共有会も行っています。

  • Ruby2.7勉強会
  • Hanami勉強会
  • Kustomize共有会
  • Skaffold共有会
  • Datadog共有会
  • BigQueryへのデータ連携の仕組み共有会
  • etc...

ポストモーテムを書く文化がある

障害が発生した際には、ポストモーテムを書く文化があります。 障害内容のドキュメント化、根本原因の理解、再発防止策の検討・導入などを目的としています。 また、当たり前のことではありますが、犯人探しをして非難したりする場には決してならず、心理的安全性が高いです。 個人的には、障害対応のプロセスを振り返ることで苦手なインフラ周りの知見を高めることができるので、大変ありがたいです(障害は発生しないに越したことはありませんが)。

4半期ごとに社内LT大会が開催される

社内で開発された機能の共有だけでなく、各チームが開発する上で行った技術的な取組についても学ぶため、4半期ごとにLT大会を開催しています。

ユーザーの声を聴く習慣がある

Slack上でユーザーからの問い合わせが流れてくるようになっており、エンジニアもそれに目を通す文化があります。問い合わせにはカスタマーサポートと連携して対応にあたっています。 また、Studyplusを使用しているユーザーへのインタビューにエンジニアも参加できます。

褒め合う文化がある

これは私が所属するサーバーグループの話になりますが、大きな成果を上げたときだけでなく、小さなことでもメンバー同士がSlackで褒め合うというすてきな文化があります。

改善タスクを優先する時間を設けている

これも私が所属するサーバーグループの話になりますが、毎週金曜日の午後は改善タスクを優先する時間としています。これがないと日々のタスクばかりを優先し、技術的負債が増えてしまうため、そうならない工夫として時間を設けています。

細かいことですが

ミーティングの曜日が割と決まっている

開発中、合間にミーティングが入るとどうしてもスイッチングコストが高くなってしまいます。 それを防ぐため、サーバーグループではミーティングはなるべく火〜木曜日に設定し、月曜日と金曜日を開発に集中する作業デーとしています。

有給取得の自由度がかなり高い

有給取得の際は、前日までにSlackに投稿しておけば良いというかなり自由な環境です(常識の範囲内でチームに迷惑をかけない程度になりますが)。 もちろん取得日がわかったら早く共有するに越したことはありませんが、「あ!」という日や、「推しが急に...」な人にも優しい環境です。

最後に

最後まで読んでいただきありがとうございます。

いかがでしたでしょうか。弊社のすてきなところ、伝わりましたでしょうか。また、少しでもスタディプラスに興味を持っていただけたでしょうか。

現在スタディプラスでは、サーバーサイドエンジニアを募集しています!

詳しくは下記をご覧ください。 open.talentio.com

Ruby / Railsにおけるカレンダー機能を振り返る

こんにちは、ForSchool事業部サーバーサイドエンジニアのましばです。

Studyplus for Schoolでは3月にカレンダー機能をリリースしました。 色々と大変なこともあったので振り返りを含めて記事にしたいと思います。

iCalendarについて

カレンダー機能では、生徒や先生が登録した学習計画をiOSやAndroidのカレンダーアプリ上でも確認できる必要があります。
今回の実装ではカレンダーアプリと連携する手段としてiCalendarを使用しました。
iCalendarはカレンダーやスケジュールをインターネット上でやりとりするためのデータフォーマットであり、RFC5545で定義されています。

iCalendarでは、予定はイベントコンポーネントによって定義されます。
例えば、ある単発の予定は以下のようなイベントとして記述できます。

BEGIN:VEVENT
DTSTART:20210521T190000Z
DTEND:20210521T200000Z
SUMMARY:ブログ記事を書く
END:VEVENT

カレンダーといえば繰り返しの予定ですが、これはイベント内にRRULEを追加することで対応できます。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
SUMMARY:お昼ごはん
END:VEVENT

このように記述すると、毎日繰り返しの予定を定義することができます。

RRULEにはいくつか設定できる項目があります。以下に例を挙げます。

  • INTERVAL: 予定を繰り返す間隔。DAILY;INTERVEL=2とすれば1日おきの予定になります。
  • UNTIL: 繰り返しの終了を設定できます。
  • COUNT: 予定を繰り返す回数を設定できます。

繰り返す条件も様々な指定方法があり、毎日、毎週、毎月や日付指定、曜日指定なども可能です。

その他に、予定を作成しない例外の条件としてEXRULEEXDATEを指定することができます。
例えば以下のようにEXDATEを設定すると、5月31日にこの繰り返しの予定は定義されないようになります。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
EXDATE;TZID=Asia/Tokyo:20210531T120000
SUMMARY:お昼ごはん
END:VEVENT

ice_cubeによる実装

ForSchool事業部ではサーバーサイドをRuby / Railsで開発しています。
iCalendarを扱うgemはいくつかありますが、繰り返しの予定を多く扱うことや、ドキュメントの量なども加味して、今回はice_cubeを利用しました。
使い方は直感的で、IceCube::Scheduleクラスのインスタンスを作成し、必要な条件をメソッドで設定していくだけです。

schedule = IceCube::Schedule.new(Time.zone.parse('2021-05-21 10:00:00', end_time: Time.zone.parse('2021-05-21 11:00:00'))
# 毎日繰り返しの予定として設定
schedule.add_recurrence_rule(IceCube::Rule.daily)
# 22日から30日までに存在する予定を取得
schedule.occurrences_between(Time.new(2021, 05, 22), Time.new(2021, 05, 30))
# => [Sat, 22 May 2021 10:00:00 JST +09:00,
#  Sun, 23 May 2021 10:00:00 JST +09:00,
#  Mon, 24 May 2021 10:00:00 JST +09:00,
#  Tue, 25 May 2021 10:00:00 JST +09:00,
#  Wed, 26 May 2021 10:00:00 JST +09:00,
#  Thu, 27 May 2021 10:00:00 JST +09:00,
#  Fri, 28 May 2021 10:00:00 JST +09:00,
#  Sat, 29 May 2021 10:00:00 JST +09:00]

# 25日は例外に設定
schedule.add_exception_time(Time.zone.parse('2021-05-25 10:00:00'))
# 24日の次の予定が26日になっている
schedule.next_occurrence(Time.zone.parse('2021-05-24 10:00:00'))
# => Wed, 26 May 2021 10:00:00 JST +09:00

大変だった点

繰り返しデータのモデル化

カレンダーにおいて、繰り返しの予定は終了期限を設定しない限り半永久的な予定となります。
そのため、全ての発生するイベントをデータとして保持することは現実的ではありません。
今回の実装では、開始日の予定と繰り返し条件のみデータとして保持し、その後の予定はice_cubeのメソッドにより取得するようにしました。
なので、あるユーザーの特定の期間の予定を取得する場合は

  1. ユーザーの開始日の予定を取得
  2. 繰り返し条件と組み合わせて繰り返しの予定を取得
  3. 期間内に含まれるものを返す

といった処理になります。
なかなか複雑な実装だったので、検証するのが大変でした。

予定の編集、削除処理

最も苦労したことが、予定の編集と削除に伴うデータの更新処理でした。
例えば、ある日付以降は異なる条件の繰り返し予定に編集したい場合があります。
その場合、iCalendarでは古い条件にUNTILを設定し、新たなイベントを追加で作成します。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
UNTIL;TZID=Asia/Tokyo:20210531T235959
SUMMARY:お昼ごはん
END:VEVENT

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210601T121000
DTEND;TZID=Asia/Tokyo:20210601T131000
RRULE:FREQ=DAILY
SUMMARY:10分からお昼ごはん
END:VEVENT

一方で、もともとの予定の開始日からすべての予定を変更したい場合は、単に条件を書き換えるだけで可能です。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T121500
DTEND;TZID=Asia/Tokyo:20210521T131500
RRULE:FREQ=DAILY
SUMMARY:15分からお昼ごはん
END:VEVENT

このように、予定全てを変更するか、予定の途中以降を変更するかで期待する動作が異なります。
利用しているユーザーが予定を変更する時、その予定が開始予定日のものかそれ以降のものをかを意識することはありません。このため、リクエストが来た際に変更日時が予定開始日に相当するのかそれ以降の日時なのかをサーバー側で判断し、その後の処理を分岐させていく必要があります。
この他にも、変更条件や予定のバリデーションなども含めると想定が必要なケースがかなり増えてしまい、テストがとても大変でした。

最後に

非常にざっくりとでしたが、カレンダー機能の実装について振り返りました。
スケジュール的にもかなりタイトで大変でしたが、大きな不具合もなくリリースできてよかったです。
また、世の中のカレンダーアプリがいかにすごいかを実感することになりました。Googleカレンダーは半端ないですね。

最後に宣伝です。
Studyplus/Studyplus for Schoolではエンジニアを募集中です。
カジュアル面談から対応可能なのでご興味がある方はぜひご連絡ください。 www.wantedly.com open.talentio.com