Studyplus Engineering Blog

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

New Relicを活用したアプリケーションのパフォーマンス改善の流れ

こんにちは。サーバーグループ エンジニアの山田です。

サーバーグループの仕事の一つにアプリケーションのパフォーマンス改善があります。
今回は普段行っているRailsアプリケーションのパフォーマンス改善の流れについて紹介します。

遅い処理を見つける

前提として遅い処理、遅くなった処理を知る必要があるので、APMなどを使って確認します。
弊社のRailsアプリケーションではNew Relicを使用しているためその画面で説明していきます。APIのレスポンスタイムの改善を行う場合はまず以下を確認することが多いです。

  • Transactions > Most time consuming
  • Transactions > Slowest average response time

Transactions > Most time consuming

リクエスト数上位のAPI(コントローラのアクション)。この上位を改善できると効果が大きい。

Transactions > Slowest average response time

平均レスポンスが遅い上位API。一概には言えないがレスポンスに時間がかかっているためユーザー体験が悪くなっている可能性が高い。

改善対象のAPIを決める

上に挙げた二つを中心に見て、効果が大きくユーザー体験も改善されるであろうAPIを対象にしていきます。

いつ確認しているか

現状はチームでのNew Relic確認は週次のスプリントイベント内で見るようにしています。ただし毎回必ず見ているわけではなく、以下のような状況の時に特に重点的に確認しています。

  • 突発的なアクセス数の増加(例えばコロナの影響)やパフォーマンスに影響を与えそうな修正を入れた時など
  • パフォーマンスに関わる問題発生が多くなっている時

以前は必ず毎回のスプリントイベントの中で見るようにしていました。しかしパフォーマンス面の変化が起こることは少なかっため、全員で見る頻度は少なくしました。

ボトルネックとなっている処理の調査

ここからは最近行った改善を例に説明していきます。 Studyplusアプリの中心である勉強を記録するAPIが、想定よりも時間がかかっていることがわかったためその改善を行っていきました。

New Relicの Transaction trace > Trace details で詳細を確認すると

MySQL StudyRecord find からMySQL StudyRecord create の間で2秒以上かかる場合があるとわかりました。ここを中心に調査していきます。

ローカル環境で確認する

パフォーマンス問題はデータ量に起因している場合が多いため、本番以外の環境で再現できない場合は多いです。しかし何からしら解決の手掛かりがないかローカルの環境で確認します。

何をどうやって確認するかはその時によって様々で、ソースコードを見てすぐに原因がわかる時もあればそうでない時もあると思います。

今回は rack-lineprof というGemを使って調査を行いました。 時間がかかっているであろう処理の周辺で確認をしていきます。

  10.4ms     2 |  135          record.study_unit = bookshelf_entry.study_unit
               |  136        end
               |  137
   9.2ms     1 |  138        record.save!
  84.4ms     1 |  139        record.create_event!
               |  140
              .......

結果としてNew Relicで測定される結果ほど顕著に時間がかかっている箇所は見つけられなかったため、より詳細を確認できるように本番にログを仕込む方向にしました。

ログを仕込む

New RelicのMethod tracers を使えば簡単にメソッドのトランザクション内の時間を計測して、New Relicで表示することができるのでそれを使っていきます。

今回の例では、リクエスの中で呼ばれているStudyRecordというモデルの以下のメソッドを計測するようにしました。

  • study という独自に作成したクラスメソッド
  • save! というActiveRecrdのモデルオブジェクトをDBに保存にするインスタンスメソッド
+ require 'new_relic/agent/method_tracer'

class StudyRecord < ApplicationRecord
+  include ::NewRelic::Agent::MethodTracer

  # save! メソッドをトレースする。 New Relic上は Custom/study_record_save という名前で表示する
+  add_method_tracer :save!, 'Custom/study_record_save'

 # study というクラスメソッドをトレースする。 New Relic上は Custom/study_record_study という名前で表示する
  class << self
+    include ::NewRelic::Agent::MethodTracer

+    add_method_tracer :study, 'Custom/study_record_study'
  end
end

ログを仕込んだ結果

先ほどのTrace detailsよりもどのメソッドで時間がかかっているかが詳細に見れるようになりました。StudyRecord#save! を実行してcreateのSQLの実行が完了するまでに時間がかかっていることが明確になりました。

また、SQL実行時間を確認するとcreateのSQL(insert)は時間がかかっていませんでした。そのため直前の処理が原因であるとわかります。

原因特定と修正

save! の内で重そうな処理は、バリデーション以外にはなさそうだったためそこを中心に確認しました。 本番と同等のデータで確認した結果、あるバリデーションの処理に時間がかかっていることがわかりました。

そのバリデーションは特定の条件で実行すればよいが、必要ない場合も無条件で実行するようになっていました。
そのため条件を満たす場合に実行するという1行の修正を加えました。

効果測定

修正した後に本当に速くなっているか、どれぐらい速くなっているかの測定を行います。

今回の修正では対象のAPIの全てのリクエストが改善できたわけではないため、修正前後の1週間分の平均レスポンスタイムで確認しました。

平均レスポンスタイム
修正前 0.88 sec
修正後 0.33 sec

無事にレスポンスタイムが短くなっていることを確認できました🎉

最後に

例を交えてRailsアプリケーションのパフォーマンス改善の流れについて紹介しました。 普段どのような流れで行っているかが伝われば幸いです。

個人的にパフォーマンス改善の仕事は、可能性を絞って原因を特定していく感じが問題を解くような楽しさがあり好きな仕事の一つです。 今後も継続的に改善を行っていきたいです。

We Are Hiring

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

EKSのCluster AutoscalerでNodeのスケールイン時に502、504エラーがでるのを解消した

チャオ。SREチームの栗山(@sheepland)です。 好きな漫画は「僕の心のヤバイやつ」です。毎回心がバキバキになりながら読んでいます。

今回はEKSでCluster Autoscalerを使った際にNodeがスケールインするタイミングで502、504エラーがでるのを解消した話です。

TL;DR

  • EKSでCluster Autoscalerを使う場合、Ingressはtarget-type: ipにする
  • target-type: ipにした場合、PodのpreStop内で長めにsleepを入れる

前提

  • ALBの管理にはAWS ALB Ingress Controllerを使用 (※近いうちにAWS Load Balancer Controllerにバージョンアップ予定)
  • トラフィックモードはデフォルトのInstance modeを使用
  • Podの終了時に新規リクエストが来なくなるのを待つためにのpreStopの中でsleepを10秒入れている

事象

Cluster Autoscalerを導入するにあたりNodeのスケールアウト/スケールイン時にリクエストエラーが発生しないかを検証していました。検証方法は簡単でLocustという負荷試験ツールを使ってひたすらリクエストを投げるというものです。リクエストエラーが発生すればLocustの画面のFailuresタブから分かります。
image.jpeg (96.4 kB)

スケールアウト時には問題なかったのですが、Nodeがスケールインするタイミングで数リクエストが502や504エラーになるという問題が発生しました。 最初はスケールインするNodeにのっているPodの退避がNodeのスケールインに間に合わずエラーが発生しているのかと思っていました。
しかし調査をするとNodeがスケールインする前にPodは退避されているというのと、スケールインするNodeにアプリケーションPodがのっていない場合でもリクエストエラーが発生していたためPodのターミネート処理に問題があるのではなく、ALB周りになにか問題があると推測。
ここからは想像ですがNodeが終了するときに他のNodeのiptablesを更新するときに一部のリクエストがエラーになっているような印象をうけました。

Ingressのtarget-typeを変えてみる

Ingressのトラフィックモード(target-type)がデフォルトのInstance mode から IP mode に変更してみました。 そうするとNodeのスケールイン時にリクエストエラーが発生しなくなりました 🎉

しかし別の問題が…

今度はPodが終了するときにリクエストエラーが発生するようになりました。Instance modeのときは発生しなかった事象です。 しかもNodeのスケールイン時のリクエストエラーよりもエラー数が多くなってます。

preStopのsleepを伸ばしてみる

色々ネットで調べてみるとIP modeにするとALBのターゲットグループからPodを登録解除するのに時間がかかるということでした。
(参考記事: スマホゲームの API サーバにおける EKS の運用事例 | エンジニアブログ | GREE Engineering)
そのため10秒sleepするだけではターゲットグループからの解除が間に合わず、Podが終了しているにも関わらずリクエストがPodにきていたと推測されます。 実際にpreStopの中でsleepを10秒から30秒に伸ばしたところリクエストエラーが発生しなくなりました 🎉

まとめ

Cluster AutoscalerのNodeのスケールイン時のリクエストエラーの解消方法について紹介しました。
スケールイン時のリクエストエラーは80RPSくらいの負荷の中で1回リクエストエラーが発生するくらいだったので、リクエストがそこまで多くないサービスであれば気づかなかったり問題にならなかったりするかもしれません。しかし大きなサービスになるとこういった問題が顕在化してきます。
やはり事前検証をしっかり行うのは大切ですね。調査当初は何が原因か分からずかなり焦りましたが無事解決できてよかったです。

リモートチームでスクラム開発

こんにちは、ForSchool事業部の島田です。好きな漫画は「王様達のヴァイキング」です。

スタディプラス社では、現在リモートでの開発が主体となっています。その状況の中でStudyplus for School(以下FS)開発チームはスクラムによる開発を進めています。

今回はFS開発チームのリモート環境下における開発プロセスを紹介したいと思います。

(ちなみに、Studyplusのサーバーサイドチームについてはこちらの記事を参照していただければと思います)

tech.studyplus.co.jp

開発プロセスについて

以下が、FS開発チームのスクラムの概要です。

  • スプリント期間:2週間
  • デイリースクラム:毎日30分
  • スプリントプランニング:隔週2時間
  • スプリントレビュー:隔週2時間
  • スプリントレトロスペクティブ:隔週2時間

デイリースクラム

スプリントゴールの達成に対して課題・障害がないかを確認するミーティングです。 スプリントバッグログの進行具合を中心にして、チームがその日に取り組んだ事を確認していきます。その中でこのまま開発を進めて計画どおりに行かないと考えられる場合は課題を明確にして(別のミーティングを設けるなどして)解決策を考え、早い段階で軌道修正できるようにしています。

スプリントプランニング

スプリント期間の最初に行われる、スプリントの作業を計画するイベントです。

スプリントで実現するバックログの項目を選択してスプリントで実現するタスクにしていきます。

選択する項目は、機能開発や技術的な改善も含まれています。それらを具体的にどう実現していくかをタスク化して見積もります。

以下のステップで進めています。

  1. スプリントバックログの優先度を決める
  2. バックログの見積もりをする
  3. これまでのベロシティを参考にしてスプリントゴールを決める

スプリントレビュー

スプリントの最後に行われるスプリントの成果物をレビューするイベントです。プロダクトオーナー、開発者を中心にして、レビューをしてフィードバックをしていきます。その結果により新たなバックログが追加されたりします。

  1. スプリントの成果物のレビュー・デモ
  2. レビューに応じて、必要ならば新たなバックログを追加

スプリントレトロスペクティブ

スプリントレビューの後に、チームをより良くするために改善策を話し合うイベントです。 スプリントを振り返り、ゴールの達成度(ベロシティはどうだったか)を確認します。発生した課題の解決策やチームをより良くするための改善案ついて話したりします。 FS開発チームでは話し合いの際に、Lean Coffeeのやり方を参考にしてすすめています。以前はKPTで進めていました。しかし各自の課題感が発散した場合に議論の優先度を付けるのが難しくなり、本当に話し合うべき事を話す時間が足りなくなる事も少なくありませんでした。このやり方と後述するScatterSpokeというツールの相性もあり、チーム全体で重要だと思われる課題から優先的に話していけるようになり、ミーティングの質が向上したと感じました。

  1. Brainstorm(5分)
    1. 各自がスプリントで気になったトピックを記載する
    2. 各自二票まで全員があげたトピックの中で気になったトピックへ投票する
  2. To Discuss
    1. 投票数があったトピックをピックアップする
    2. 投票数が多い順に議論を始める
  3. Discussing
    1. 7分議論する
    2. 時間が経過したら、このまま継続して議論するかを親指のサインで投票する
      1. 継続の意思が過半数以上の場合-> 4分議論、過半数に満たない場合 -> 2分議論
  4. Discussed
    1. 全員がこれ以上の議論が必要ないとなったら、当該トピックの議論を終える
    2. 必要に応じて改善アクションを決める

以上が、FS開発チームのスプリントイベントの内容になります。

ツールについて

FS開発チームで利用しているツールについて紹介したいと思います。

Slack

ほぼ全てのやりとりはチャット上でやります

Zoom

定例のミーティングは主にZoomを利用しています。サクッと話したい場合にはSlack Callなども使います。

monday.com

バックログ・タスクの管理ではmonday.comを利用しています。詳しいツールの説明についてはこちらの記事を参照していただければと思います。

tech.studyplus.co.jp

esa

仕様や議事録などesaで管理しています。必要に応じてGoogelスプレッドシートを利用したりもします。

Scrum Poker Online

スプリントプランニングの見積もりで利用しています。オンラインでプラニングポーカーが出来るツールは数多くありますが必要最小限の事が実現できるので重宝しています。以前はSlack上で見積もりの数を出すなどもしていたのですが、やはりタイミングや一覧性などもあり課題がありました。このツールは無料で、簡単にroomをつくれて入ることができ全員の見積もりが揃ったらポイントを確認することが出来るので大変便利です。

f:id:yo-shimada:20210222093957j:plain
Scurm Porcker Online

ScatterSpoke

スプリントレトロスペクティブで利用しているツールです。

振り返りを実施する際に各自が課題をあげて投票を行うために利用しています。リモート以前は付箋を利用するなどしてました。リモート後はesaなどに記載していましたが、ドキュメント共有ツールだと他者が記載している内容や投票がわかるので、自分以外の意見に影響を受けてしまう可能性もありました。

ScatterSpokeを利用することにより、

  • 他者の振り返りトピックの内容・投票が公開されるまで分からない
  • タイマー機能で制限時間がわかりやすい
  • トピック内容が被った場合にグルーピングしやすい
  • Slack連携がありアクションアイテムを確認する事ができる

などのメリットがあります

f:id:yo-shimada:20210222095756j:plain
ScatterSpoke

その他

その他にリモート環境下だと中々きっかけがないとコミュニケーションが発生しなかったりする事があるので、開発チームが話し合うチャンネルにSlackのリマインダーで「時報」をするようにしています。今やっている事を簡単に書いたり、困ったことなどを気軽に投稿したりしています。

f:id:yo-shimada:20210222100126p:plain

最後に

世間にはリモートで生産性をあげるためのツールは数多くありますが、チームやメンバーの状況に応じて合ったツールを選ぶ事が重要だと思いました。

現時点でのFS開発チームは様々な紆余曲折の末に以上のような開発プロセスを行っています。

これも日々のスクラムでの振り返りによる成果であり、半年後にはまた何かやり方を変えている可能性があります。リモートやリアルにかかわらず、大切なのは現状に満足することなく常により良くできないかを考えることだと思いました。

幸いな事として会社やチームは新しいツールや仕組みの導入を試す事に対して寛容なので(もちろん、それ相応の必要性がないとダメですが)、それも改善の後押しになっていると感じています。

Dagger の Assisted Inject 統合とマイグレーション

こんにちは、モバイルクライアントグループの中島です。 年末少し膝を痛めてしまいランニングを中断していたのですが、そろそろ再開していきたい今日この頃。

さて、今回は Dagger が Assisted Inject を統合したことによるマイグレーションについてお話ししたいと思います。 特に、WorkManager と、2/9にリリースされた v2.32 の変更部分とでつまづいたことについてお話したいと思います。

Assisted Inject とは

簡単に言えば、Daggerによる Inject constructor に対して、 Dagger によってinjectできるもの以外のパラメータを入れ込むための機能です。 Androidで一般的な使い所としては、固有のデータIDを用いて詳細を表示する画面などで、そのIDを不変な値としてViewModelのconstructorに入れたいときなどでしょう。

square/AssistedInject

この機能は以前より外部サポートライブラリである square/AssistedInject を用いて実現されていました。

compileOnly `com.squareup.inject:assisted-inject-annotations-dagger2:0.6.0`
kapt `com.squareup.inject:assisted-inject-processor-dagger2:0.6.0`
@Module(includes = [PresenterModule::class])
abstract class HogeModule {
    // ViewのInjector定義など
}

@AssistedModule
@Module(includes = [AssistedInject_PresenterModule::class])
internal abstract class PresenterModule
// ViewModel
class HogeDetailViewModel @AssistedInject constructor(
    @Assisted private val hogeId: String,
    private val repository: HogeRepository,
) : ViewModel() {

    @AssistedInject.Factory
    interface Factory {
        fun create(hogeId: String): HogeDetailViewModel
    }
}
// View
    private val args: HogeDetailFragmentArgs by navArgs()

    @Inject
    lateinit var viewModelFactory: HogeDetailViewModel.Factory
    private val viewModel: HogeDetailViewModel by assistedViewModels {
        viewModelFactory.create(hogeId = args.hogeId)
    }

なお、assistedVieModels はtakahiromさんの以下の記事を参考にさせていただき作成して運用している、Dagger用の拡張関数です。 詳しくはそちらをご参照ください。

qiita.com

Dagger 2.31 における公式への統合

今年の1月15日、 Dagger v2.31 のアップデートにて Assisted Injection が公式に統合されました。

github.com

これにより square/AssistedInject の依存が消え、PresenterModule など、 Module への追加記述も必要なくなりました。

// ViewModel
class HogeDetailViewModel @AssistedInject constructor(
    @Assisted private val hogeId: String,
    private val repository: HogeRepository,
) : ViewModel() {

    @AssistedFactory <- // ここだけアノテーション名が違います
    interface Factory {
        fun create(hogeId: String): HogeDetailViewModel
    }
}
// View
    private val args: HogeDetailFragmentArgs by navArgs()

    @Inject
    lateinit var viewModelFactory: HogeDetailViewModel.Factory
    private val viewModel: HogeDetailViewModel by assistedViewModels {
        viewModelFactory.create(hogeId = args.hogeId)
    }

このマイグレーションについてもtakahiromさんの記事が大変参考になりました。

qiita.com

今回つまづいたところ

本題に入っていきます。公式の Assisted Injection へのマイグレーションを行なう上で、つまづいた点が二箇所ほどありました。

  • WorkManager のビルドが通らない
  • 同じ型の Assisted パラメータが判別できない

順を追って説明していきます。

WorkManager のビルドが通らない

Studyplus Android では一部のバックグラウンド処理に WorkManager を利用しています。 WorkManager の Assisted Inject について詳しくは以前の記事をご覧ください。

tech.studyplus.co.jp

問題

ViewModelと同様にマイグレーションを行なっていたところ、ビルドエラーが発生しました。

interface ChildWorkerFactory {
    fun create(appContext: Context, params: WorkerParameters): ListenableWorker
}

class HogeWorker @AssistedInject constructor(
    @Assisted private val appContext: Context,
    @Assisted private val params: WorkerParameters,
    private val repository: HogeRepository,
) : CoroutineWorker(appContext, params) {

    override fun doWork(): Result {
        // ~~
    }

    @AssistedFactory // <- アノテーションの変更
    interface Factory : ChildWorkerFactory
}
エラー: [~~.ChildWorkerFactory.create(android.content.Context, androidx.work.WorkerParameters)]
Invalid return type: androidx.work.ListenableWorker. An assisted factory's abstract method must return a type with an @AssistedInject-annotated constructor.

該当する Dagger の生成コードを見てみます。

    @dagger.assisted.AssistedFactory()
    public static abstract interface Factory extends ~~.ChildWorkerFactory {
    }

要は何もoverrideされていないので、継承元である ChildWorkerFactory のままListenableWorkerをcreateしようとしているようです。 その結果、「@AssistedInjectのアノテーションが付与されているconstructorがないぞ」と言われているわけですね。

それならばとcreateメソッドまでoverrideして、返り値の型を「@AssistedInjectのアノテーションが付与されているconstructorを持つWorker」にしてみました。

    @AssistedFactory
    interface Factory : ChildWorkerFactory {
        override fun create(appContext: Context, params: WorkerParameters): HogeWorker
    }

その結果、またビルドエラーが出ましたがメッセージが変わりました。

エラー: 不適合な型:
 dagger.internal.Factory<HogeWorker_Factory_Impl>をProvider<~~.HogeWorker.Factory>に変換できません:

…?

FactoryをProviderに変換できない? それはそうだろうと思うのですが、そこが変わるような変更を加えた覚えがなく、色々いじっても解決することなく結局合計2日程度ここで止まってしまいました。

解決

2月9日、 Dagger v2.32 のアップデートにて解決しました。 リリースノートを見ると、 Java 7で起きる型推論issueだったようです。

余談ですが、試しにDagger v2.31.2 へ戻した上でWorkManagerのあるモジュールをJava 8でビルドしてみたところ、2日間悩んでいたのが嘘のように通りました。 Dagger 関連のコードではなく、gradleでJavaバージョンを変更することで通るようになるとは発想が至りませんでした…修行不足です。

// build.gradle(:workmanager)
compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
}

v2.32 で修正されたので「DaggerのためにJava 8でビルドする」ということはもう必要ないとは思いますが、デフォルトでJava 8になるのはいつかなぁなどとふと思いを馳せました。

同じ型の Assisted パラメータが判別できない

問題

v2.32 にすることでWorkManagerの問題は解決したのですが、また新たな壁が立ちはだかりました。 同じ型のパラメータがAssisted Injectされているとエラーメッセージが出る問題です。

エラー:
 @AssistedInject constructor has duplicate @Assisted type: @Assisted java.lang.String
解決

これについてはv2.32のリリースノートを見てすぐ解決しました。

Parameters in @AssistedFactory classes that have the same type now require a name to be set via @Assisted("foo") to disambiguate between arguments. Previously, order of parameters was used.

今までは記述された順番でパラメータの対応をしていたけど、v2.32 からは @Assisted("foo") でそれぞれに名前を設定してcreateメソッドと対応させるよ、ということですね。 やることとしては、同じ型をAssisted Injectしている箇所全てに名前を付けていくだけでした。

// ViewModel
class HogeDetailViewModel @AssistedInject constructor(
    @Assisted("hogeId") private val hogeId: String,
    @Assisted("fugaId") private val fugaId: String,
    private val repository: HogeRepository,
) : ViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(
            @Assisted("hogeId") hogeId: String,
            @Assisted("fugaId") fugaId: String,
        ): HogeDetailViewModel
    }
}

アノテーションへの値の設定もですが、createメソッドのパラメータにも@Assistedアノテーションが必要になったのは新しい要素ですね。 以前のsquare/AssistedInject では引数の名前そのものの一致で対応させていたので、自由度は増えたけど少し冗長かなという印象は否めない感じでしょうか。

終わりに

簡単ではありますが、Studyplus Androidにおける、Dagger の公式Assisted Inject対応の際に引っかかった事例を紹介しました。

Assisted Injectは、Studyplus Androidにとって必須の機能として重宝しているので、 Dagger に統合されたのは非常に嬉しいですね。 square/AssistedInject の完成度も高かったためかどうしても比較してしまう部分もあります。 ですが、issueの対応なども積極的に行なわれているので、これからもより便利になっていくだろうと思っています。

最後までご覧いただき、ありがとうございました!

DropboxのStoreで通信量を削減しました

こんにちは、モバイルクライアントグループの隅山です。 前回は両OS開発についてのブログを書きましたが、今回はDropboxのStoreを用いてAndroidアプリの通信量を削減した話をしていきます。

Storeについて

まず、DropboxのStoreとはアプリ内のデータ操作(取得、共有、保存、検索)を簡素化するライブラリです。 ネットワーク経由でデータをいつ取得するか、メモリとディスクのどっちにキャッシュするか、データをKotlinのFlowで返却するかなどを簡単に実装することができます。

最近ではネットワークを最適化することが推奨されており、データをキャッシュしてオフラインでも使用できるようにしたり、不要なネットワークリクエストを防ぐ必要があります。 今回はネットワークを最適化するための第一歩として、Storeを用いて不要なネットワークリクエストを防ぐ対応を行ったのでその話をしていきます。

導入について

ライブラリをアプリへ導入する方法はStoreのREADME.mdに記載されているので、ここでは割愛させていただきます。

github.com

導入方法

不要な通信を防ぐために導入する目的であれば、実装は非常に簡単です。 弊社のコードを具体例に説明していきます。

弊社のアプリではユーザーが自分の所属している高校を設定することができます。 その高校一覧をサーバから取得してUI上に表示している箇所が下記のコードとなります。

Repository層

class HighSchoolsRepository(private val service: HighSchoolsService) {
    suspend fun index(): List<HighSchool> = service.index()
}

ViewModel層

class StudyGoalHighSchoolViewModel(private val repository: HighSchoolsRepository) : ViewModel() {
    val highSchoolList = MutableLiveData<List<HighSchool>>()

    init {
        viewModelScope.launch {
            runCatching {
                repository.index()
            }.onSuccess {
                highSchoolList.value = it
            }
        }
    }
}

Activity層

class StudyGoalHighSchoolActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.highSchoolList.observe(this) {
            // リストとして表示
        }
    }
}

上記のコードでStoreを導入する場合、変更するのはRepository層のみです。 導入後のRepository層が下記となります。

class HighSchoolsRepository(private val service: HighSchoolsService) {

    private val store =
        StoreBuilder
            .from(Fetcher.of { service.index() })
            .build()

    suspend fun index(): List<HighSchool> = store.get()
}

APIを叩く際に引数を設定しませんでしたが、高校一覧取得APIに都道府県コードを設定する場合も簡単に実装することができます。

class HighSchoolsRepository(private val service: HighSchoolsService) {

    private val store =
        StoreBuilder
            .from(Fetcher.of { locationCode: Int -> service.index(locationCode) })
            .build()

    suspend fun index(locationCode: Int): List<HighSchool> = store.get(locationCode)
}

導入解説

Repository層からAPIを叩く際にStoreを導入しましたが、何故これで通信が削減されるか説明していきます。

まず、Storeとはアプリ内のデータ操作を簡素化するライブラリです。 APIを叩く箇所にStoreを導入するだけでは実装前後で差分は生まれません。 差分が出るのはStoreからどのようにデータを取り出すかによって決まります。

Storeからデータを取り出す方法は、現状では下記の3通りあります。

  • Store.get(key: Key):メモリ内のキャッシュかsourceOfTruthからデータ取得、取得できない場合はネットワークからのデータ取得
  • Store.fresh(key: Key):ネットワークからデータ取得
  • Store.stream():Storeのデータが更新されるとFlowでデータ返却

Storeを導入してもfresh(key: Key)でデータ取得を行えば、導入前のコードと同様の動作となります。 しかし、get(key: Key)でデータ取得を行うことでメモリ内のキャッシュかsourceOfTruthからデータを取得するため、ネットワークからデータ取得する回数を削減することができます。 メモリ内のキャッシュの生存期間はデフォルトで100個のアイテムを24時間に設定されています。*1

まとめると、弊社の導入後のコードではget(key: Key)を用いているため、24時間以内であれば再び通信することはなく通信量が削減されています。

メリット・デメリット

導入解説で細かく説明しましたが、メリットデメリットを簡単にまとめたいと思います。

メリット:

  • メモリ内のキャッシュに保存できるため通信量が削減できる
  • 端末がオフラインでもデータを表示することができる
  • Flowでデータをリアクティブに流したい場合やローカルDBに保存したい場合など拡張しやすい
  • 導入が非常に簡単

デメリット:

  • サーバのデータが頻繁に変わるAPIは導入に向かない
  • APIごとにStoreBuilderからStoreを作成するためコード量がだいぶ増える

まとめ

今回は通信量を削減することに絞ってStore導入を説明しましたが、拡張性が高く他にも多くのことができます。 弊社のアプリでは14箇所のAPIでStoreを導入し通信量の削減をしました。

今後はFlowを用いてデータをリアルタイムに更新したり、ローカルDBとのデータ操作部分に導入していきたいと思います。 他の導入方法で発見があったらまたブログでまとめていきます。

*1:生存期間はMemoryPolicyを用いて設定変更することもできます

週1回30分のフロントエンドミーティングを始めました

こんにちは。ForSchool事業部の石上です。ハライチのターンというラジオが好きです。ぜひRadikoで聴いてみてください。

今日は、ForSchool事業部で行っているフロントエンドミーティングという取り組みについて紹介します。

3行で

  • フロントエンドミーティングを始めました
  • いろいろ課題が出てきました
  • やっていくぞ

フロントエンドミーティングとはなにか

ForSchool事業部におけるフロントエンドミーティングとは、週1回チーム内でフロントエンドに話題を絞って振り返りや相談を行うミーティングのことです1

なぜやっているのか

Studyplus for Schoolのフロントエンドには、日常の業務で各人が課題を感じているのにそれを整理できていないという問題がありました。そこで、フロントエンド技術改善に絞ったロードマップを作り直すことにして、昨年の11〜12月の間にMTGを重ねて、ロードマップを作成しました。

ところが、このロードマップには少し不安が残りました。「作り直すことにしました」と書いたとおり、実はこういったものを作るのは初めてではなく、昨年の4月にも同じようなものを作っていました。ここでまた作り直しが発生してるということは、問題だと感じる点や改善案などがその当時から変わっているということです。ということは、今回つくったものも、半年後にはどうなっているかわかりません。このロードマップは、なるべく日常的に見直す必要があると考えました。

そこで、週1回、30分時間をとってこのロードマップを見直す会を設けることにしました。そして、ただ真面目に見直しだけをしても面白くないので、気になっているフロントエンド技術についての雑談などをアジェンダに盛り込みました。これが今ForSchool事業部で行っているフロントエンドミーティングです。

フロントエンドミーティングで整理できた問題

f:id:shgam:20210205092705p:plain
フロントエンドミーティングはこれまで4回開催

このフロントエンドミーティングは12月から始まり、これまでに4回開催しています。たとえば以下のような相談や問題解決がこの場でできています。

  • コーディングガイドラインを定めて、今後どのようにコードを書くかを明記していく。
  • typed-css-modulesというライブラリを利用してclassNameの型定義を生成していたが、これはwebpack設定のメンテが大変になる影響があるのでやめる。
  • 新たに必要になるウェブアプリケーションのインフラについて相談(Next.jsとVercelを採用)
  • 使いまわしにくいコンポーネントをどう整理していくか、タスクとしてどこをゴールにして進めていくか

こうやって見ると、毎週30分にしてはだいぶ実りがあるように思えます(これは昨年8月に入社してくれた@okuparaさんの専門性があってこそできていることではあるのですが2)。

もしこのミーティングがなければこういったことを整理する場もなく、問題が横たわったままだったかもしれません。やってよかった。

ちょっとしたキャッチアップの場に

フロントエンドは流れが速いとよく言われますが、フロントエンドミーティングのような場でそれぞれが気になっている技術を紹介すれば、ちょっとしたキャッチアップの場にはなると思います。これまでのフロントエンドミーティングで出てきた話題はReact Server Components, blitz, snowpackなどです。

発表資料をつくるわけではなく、「こんなのあるらしいんですけど、どう思います?」みたいな雑な話ができるので、とても楽しいです。

今後

まだまだ大小いろんな問題があるのですが、コツコツとやっていけたらなと思います。採用も積極的にしているので、手伝ってくれるエンジニアはぜひ応募してください。

www.wantedly.com


  1. フロントエンドミーティングという呼び名は、フィードフォースさんが過去にその名でフロントエンド技術の共有会を行っていた記憶があり、それを真似しました。

  2. 入社早々、脱enzyme->RTLの導入をしてくれたりしてます。すごい。https://tech.studyplus.co.jp/entry/2020/10/05/090000

Studyplus iOSアプリでWidgetに対応しました

初めまして、モバイルクライアントグループの上原です。昨年11月からiOSアプリ開発を担当しています。 最近は、Apex Legendsで目標だったランクのダイヤ4に到達し、ランクのモチベーションが下がりカジュアルをずっと回す日常になりました。

さて、本題に入ります。Studyplusでは、iOS 14から実装されたWidgetに対応し、昨年11月30日にカウントダウンWidgetをリリースしました。
今回はどのようなWidgetを作成したのか、導入経緯やTipsなどを紹介していこうと思います。

カウントダウンWidget

Studyplusでは、アプリ内でユーザが設定したイベント(模試や期末テストなど)までの日数を表示するイベントカウントダウン機能を提供しています。
上記の機能を、ホーム画面でも確認できるようにしたのがカウントダウンWidgetです。

f:id:nappannda:20210120155037p:plain:w200
カウントダウンWidget画像

Widgetの設定画面からアプリ内で設定しているカウントダウンを選択したりシンプルモードといった形で端末の外観モードに合わせた表示ができるようになっています。

f:id:nappannda:20210118072955p:plain:w200
Widget設定画面画像

f:id:nappannda:20210118073957p:plain:w200
カウントダウンWidget ライトモード
f:id:nappannda:20210118073912p:plain:w200
カウントダウンWidget ダークモード

Widget実装経緯

iOS 14から実装されたWidgetですが、Studyplusで実装に至った経緯は下記になります。

エンジニアからiOS 14の新機能のどれかを作りたい意見が出る
WidgetがSwiftUIのみで書くものだったので今後のSwiftUI環境に向けての勉強になりそう、Widgetを作りたい旨を伝える
ディレクターやデザイナーとユーザへの新しいアプローチでユーザ価値が出せるものがないかを検討
ユーザの学習に対して緊張感・危機感を高めるものとしてカウントダウンWidgetを実装

アプリを触っていただいた方には伝わるかなと思うのですが、イベントまでの日数がカウントダウンWidgetに表示されていると、スマホを開くたびに緊張感が高まり学習の習慣化を促すことができ新しい価値を提供することができたと思います。 しかし、SwiftUIの学習にWidgetが利用できたかというとViewの少しの実装に関しては利用できましたが、やはり@Stateや@ObservedObjectなどで値が更新されたらViewを更新するなどSwiftUIの肝となる部分などはWidgetではサポートされておらずSwiftUIの学習面では少し微妙だなと感じました。

実装Tips

Widgetで実装した機能のなかでどうやって実装したかどうかなどを紹介していきます。

シンプルモード ON/OFF時のviewへのShadowオンオフ

カウントダウンWidgetでは、シンプルモードではない時に特定のViewにShadowを付け、シンプルモードではShadowを付けないといった仕様がありました。 何も考えずにSwiftUIで愚直にやろうとすると下記のようなコードになります。 条件によってほぼ同じViewが存在してしまったり、見た目をカスタマイズしようとすると分岐が複雑化してしまったりと、見にくいコードになってしまいます。

let isSimpleMode: Bool
var body: some View {
    if !simpleMode {
        Text("タイトル").shadow(color: .init(red: 0, green: 0, blue: 0, opacity: 0.75), radius: 0.5, x: 0.5, y: 0.5)
    } else {
        Text("タイトル")
    }
}

上記をもっとスマートにある特定の条件式の場合であればShadowを付けたいですよね?
下記のブログで紹介されているViewにExtensionで条件に適していればクロージャーを実行、適していなければそのままViewを返すコードを実装すればこのコードがすっきりします。

extension View {
    @ViewBuilder
    func `if`<Content: View>(_ condition: Bool, content: (Self) -> Content) -> some View {
        if condition {
            content(self)
        }
        else {
            self
        }
    }
}

blog.kaltoun.cz

上記を適用したコードが下記になります。同じようなViewが複数定義されることなく条件によって何が適用されるかが分かりやすくなりました。

let isSimpleMode: Bool
var body: some View {
    Text("タイトル")
        .if(!isSimpleMode) {
            $0.shadow(color: .init(red: 0, green: 0, blue: 0, opacity: 0.75), radius: 0.5, x: 0.5, y: 0.5)
        }
}

Widgetを押した際にアプリの特定画面に飛ばしたり、Widgetからの起動を計測する

Widgetは要素を押した際にURLを渡すことができます。 この機能を利用するとURLを解析しアプリの特定画面を開いたり、Widgetからの起動を計測したりすることができます。
具体的には、widgetURLにURLを渡すことで要素を押した時にそのURLが開くことになります。
注意事項としてWidgetのサイズがSmallでは、一つしか遷移に利用できません。Medium以上だと複数のwidgetURLを定義して利用することができます。

var body: some View {
    VStack {
        Text("タイトル")
        Text("サブタイトル")
    }.widgetURL(URL("app://countdown"))
}

実装ではまった&困惑したところ

TextのfontSizeを48以上に指定するとSimulator上で一瞬表示された後、消える

Xcode 12.1 ~ 12.3時点でビルドしたSimulatorで発生することを確認した挙動です。 Simulatorのみで起きており実機では再現しないのでfontSize 48以上の指定で実装した場合は、実機で確認する必要があります。

Widgetの処理がブレークポイントで止まらない

ビルド後に上部メニューからDebug->Attach to Processを選択しその中からWidgetを選択すると止まるようになります。
時々止まらないこともあるので、その時は端末からWidgetを削除したりXcode再起動を試すと上手くいくと思います。

最後に

Widgetはいろいろ制約がありますが、その制約が強いことでユーザにシンプルな情報を提供できるように感じました。 そして、制約の強さがWidgetの実装が複雑化しないようになっているのかなと実装していて感じました。
また、新しい機能ということもあり実装情報が少なかったり、予期せぬ動作が起きたり実装していくなかで様々なことがありましたが新しいものに触るのは大変面白くいい経験でした。