1
/
5

オフライン利用を考慮したモバイルアプリにおける複数端末間でのデータ同期について

Photo by Hardik Sharma on Unsplash

こんにちは!AndroidエンジニアのYukiです!
私事ではありますが、プログリットに入社し、1年が過ぎました。
日々の業務において、学びが多く、充実しているなぁと感じる、今日この頃です。

さて、今回は、2023年3月に「学習アプリ」の「復習単語をサーバで保存する」対応を例にとり「オフライン利用を考慮した、複数端末間でのデータ同期」について、お話します。

学習アプリとは?

プログリット受講生向けに提供されている英語学習用アプリです。
学習アプリ上では、4つの学習機能が提供されており、プログリットの英語コンサルタントからのコーチングを受けながら、学習アプリ上で、英語学習を進めていきます。

また、一部の機能(多読、英単語)に関しては、オフライン環境でも学習できるように設計されており、受講者は、場所を問わない、シームレスな学習が可能となります。

今回の機能開発における要求仕様

「英単語」の学習では、分からなかった単語を復習単語として登録し、後で見直すことができます。

アプリのアップデート前までは、復習単語として登録されている単語IDのみを SQLite に保持し、各単語に対して、復習単語かどうかを判定していました。しかし、復習単語の情報をローカルに保存する場合、以下の問題が生じます。

  1. アプリの再インストールもしくはログアウトした場合、復習単語の情報は消去されてしまう。
  2. 複数端末で学習をしている場合、復習単語が端末間で同期されない。

そこで、プロダクト開発チームとして、以下の要求を満たす機能を実装することになりました。

  • 復習単語の情報をサーバで保存し、上記2点のペインを解消する。
  • オフライン学習を、引き続きサポートする。

データ同期の方法について

アプリ内の処理ロジック

文章で説明する前に、イメージを持ってもらうため、以下の図をご覧ください。


オフライン利用時

オフライン利用時は、SQLiteに保持している復習単語の情報を取得し、UIレイヤーへ返却します。
至ってシンプルです。笑

オンライン利用時

オンライン利用時、まずサーバに保存されている復習単語の情報を取得します。
その後、SQLiteに登録している復習単語を取得し、各単語のそれぞれのタイムスタンプを比較します。

サーバで保存されているデータが最新の場合、SQLiteを更新します。
SQLiteで保存されているデータが最新の場合、サーバへ最新のデータを送信し、サーバのデータを更新します。
APIの定義は以下の通りです。

{
  "pins": [
    {
      "word_id": 1,
      "pin": true,
      "timestamp_of_pin_changed": "1997-07-16T19:20:30+09:00"
    },
    {
      "word_id": 2,
      "pin": false,
      "timestamp_of_pin_changed": "1997-07-16T19:20:30+09:00"
    }
  ]
}

ここで、重要なポイントが2点あり、それぞれについて、次章で、詳しくご説明します。

  • アプリのアップデート前に保存していた復習単語の取り扱いに関して
  • オフライン学習のサポートに関して

アプリのアップデート前に保存していた復習単語の取り扱いについて

アプリのアップデート前に登録していた復習単語に関して、端末内では、復習単語として登録されていた単語 IDのみ保存していました。

つまり、アプリのアップデート前の、各単語に対するタイムスタンプは存在しないため、複数端末で学習をしていた場合、どちらの端末のデータが最新なのかを判定できません。

そこで、アプリアップデート前に登録していた復習単語に関して、どちらか一方の端末で、復習単語として登録されていた単語は、サーバにその単語を復習単語として登録することにしました( ≒ OR同期)。

具体的に、サーバへ復習単語の情報を送信する際、タイムスタプを null に設定し、リクエストを行います。

{
  "pins": [
    {
      "word_id": 1,
      "pin": true,
      "timestamp_of_pin_changed": null
    },
    {
      "word_id": 2,
      "pin": false,
      "timestamp_of_pin_changed": null
    }
  ]
}

送信されたタイムスタンプが null だった場合、サーバで保持している復習単語の判定用フラグと、アプリから送信された復習単語の判定用フラグをチェックし、どちらか一方でも true だった場合、trueとして登録( ≒ OR同期)するようにしました。

例えば、iPhoneとiPadで学習を進めていたとして、iPhoneでは、park と言う単語を復習単語として登録しておき、iPadでは、復習単語として登録していなかった場合、上記のロジックに基づき、park を復習単語としてサーバに登録するようにしました。

しかし、上記のロジックを採用した場合、デメリットが発生します。
ユーザにとって、意図せず、復習単語として登録されてしまう単語が発生してしまいます。
例えば、iPad で復習単語として登録していた park を、後日、iPhoneで学習し、復習単語の解除を行った場合、アプリのリリース後、iPhoneでは、再度 park が復習単語として登録されてしまいます。
しかし、登録していた復習単語が消えてしまうことのデメリットの方が大きいため、ピンが増える方向に動くことを許容することにしました。

オフライン時の単語学習について

オフライン環境では、復習単語をサーバへは保存できません。そこで、Android Jetpack ライブラリの WorkManager を採用しました。

WorkManagerを利用することで、アプリのプロセス状況に依存せず、API通信なども可能になります。例えば、オフライン時、復習単語のデータをサーバに送信する際、オンラインに復帰したタイミングで、リクエストを行うことが可能です。

@HiltWorker
class PostReviewWordWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted private val params: WorkerParameters,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
      ...
    }

    companion object {
        const val PARAM_REQUEST = "request"

        fun enqueue(requests: List<ReviewWordEntity>, context: Context) {
            val constraints = Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .build()

            val params = workDataOf(PARAM_REQUEST to requests.toJson())

            val req = OneTimeWorkRequestBuilder<SampleWorker>()
                .setConstraints(constraints)
                .setInputData(params)
                .build()

            WorkManager.getInstance(context).enqueue(req)
        }
    }
}
@Singleton
class SyncStudentWordPinsRepository @Inject constructor(    
    private val reviewWordDao: ReviewWordDao,    
    private val reviewWordPinsApi: ReviewWordPinsApi,    
    @ApplicationContext private val context: Context,
) {    
    suspend fun syncAllWords(studyType: WordStudyType) {        
        withContext(Dispatchers.IO) {            
            val serverData = studentWordPinsApi.getStudentWordPins()
            val localData = reviewWordDao().getAll()            
            val pinsToSendServer = checkDiffBetweenLocalAndServer(                
                localDataList = localData,                
                serverDataList = serverData,            
            )
            postPinsToServer(updatePins = it)
        }
    }
    
    private fun postPinsToServer(pins: List<WordPins>) {        
        val requests = pins.map {            
            ReviewWordEntity(                
                wordId = it.wordId,                
                wordStudyType = it.wordStudyType.toEntity(),                
                isPinned = it.isPinned,                
                pinChangedAt = it.pinChangedAt?.toOffsetDateTime(),            
            )        
        }        
               // Note: WorkMangerを利用し、復習単語をサーバへ保存する。
        // Note: オフラインの場合、オンラインに復帰したタイミングで、リクエストを行う。
        PostReviewWordWorker.enqueue(            
            requests = requests,            
            context = context,        
        )    
    }
}

リリース後に発生した問題

アプリのアップデート後、一時的に、サーバのCPU使用率が上がり、他のエンドポイントの処理速度が低下してしまう問題が発生しました。
アプリのアップデート直後は、SQLiteに登録されていた復習単語のデータをサーバに送信する必要があり、この際、SQLiteに登録していた復習単語のデータをサーバに一括で送信してしまったからです。
かつ、今回はアプリの強制アップデートをかけていたことから、多数のユーザからのAPIリクエストがあり、サーバ側でタイムアウトが発生してしまいました。
対応方法として、1リクエストあたりの送信データを最大100件にすることで、事なきを得ました。
近年のコンピュータの性能は高く、パフォーマンスを気にした実装を蔑ろにしてしまいがちで、気をつけないといけないなぁと感じました。

困難だったこと

サーバから返却される復習単語のデータとアプリ内で保持しているデータの同期を実装する際、どういうアーキテクチャで実装すべきかを非常に悩みました。
今回は、コマンドとクエリの分離(Command-Query Separation: CQS)の原則に従い、データの同期処理用のRepositoryクラスとデータ取得用のRepositoryクラスを分け、それぞれViewModel から呼び出すことにしました。

復習単語のデータ取得時は、以下の図にある通り、SQLiteに保存している復習単語のデータが更新される度に、UIレイヤーに通知するようにしました。

また、ローカルとサーバのデータ同期時は、ViewModel が初期化されるタイミングで、データ同期用のRepositoryクラスを呼び出します。Repositoryクラス内で、サーバおよびSQLiteの復習単語のデータを取得したのち、各単語のタイムスタンプを比較し、必要に応じて、サーバおよびSQLiteのデータを最新のデータに更新します。

最後に

今回の実装は非常に難解でしたが、色々と学ぶことが多かったです!
まだまだAndroidエンジニア歴は1年と未熟ですが、一歩ずつ成長していけたらいいなぁと思っています。

株式会社プログリットでは一緒に働く仲間を募集しています
4 いいね!
4 いいね!
同じタグの記事
今週のランキング
株式会社プログリットからお誘い
この話題に共感したら、メンバーと話してみませんか?