LoginSignup
1
1

More than 3 years have passed since last update.

Android 学習記録 リポジトリパターン

Posted at

目的

ここ半年、サーバサイドの開発ばかりやっていてAndroidの開発やってないなぁと思い、リハビリがてら勉強しようと思いました。
リポジトリパターンを使った実装というのをやったことがないので、試しにやってみたいと思います。
自分のための勉強メモとしての意味が強いので、他人から見たらよくわからないかもです。すみません。

私のAndorid開発のレベル

開発期間
  • 1年ちょいくらい
言語
  • Java(業務で使用)
  • Kotlin(プライベートで少し触ったくらい)
知ってること
  • Androidの基礎知識は大体。
  • アーキテクチャはMVVMだけ(他にあまりメリットを感じない)。
  • Retrofit+RxJavaは多少経験あり。
知らない事
  • 割とモダンなこと(流行とか)は知らない
  • リポジトリパターンも概念しか知らない
  • Dagger(AndroidというよりJava?)も使ったことない。

学習サイト

以下のリンク先で勉強!
https://developer.android.com/courses/kotlin-android-fundamentals/overview
https://developer.android.com/codelabs/kotlin-android-training-repository?index=..%2F..android-kotlin-fundamentals#0

2021年1月時点での記録なので、ご了承ください。

環境

Windows10
AndroidStudio 3.6.3
でやってみたところ、

 * Exception is:
com.intellij.openapi.externalSystem.model.ExternalSystemException: This version of Android Studio cannot open this project, please retry with Android Studio 4.0 or newer.

ふ~~ん...
半年前くらいにインストールしたAndroidStudioでは古くて動きませんでした。
というわけでバージョンアップ。

参考:バージョンアップ方法
AndroidStudioのバージョンアップ簡単で良いですね。

Androidの技術サイクルは早いなぁ~と感心しつつ
AndroidStudio 4.1.1
にバージョンアップ完了!

学習記録

ソースコードのウォークスルー

起動

起動ボタンを押したところ

Failed to install the following Android SDK packages as some licences have not been accepted.
   build-tools;29.0.2 Android SDK Build-Tools 29.0.2

AndroidStudioのバージョンといい、あんまり動かなくて挫けそうになりましたが、[Tools]->[SDK Manager]からSDK(APIレベル29)を追加。
(いきなり学習サイトの途中から始めてますが、本当は前提の環境が決まってるんですかね...?)

ということで、もう一度リトライ!

画面イメージ

あれ、もう完成されてる...
本当はネットワークエラーが起きるところから始まるっぽいのですが、もういいや感があったので続行します。
(ちゃんとstarterプロジェクトからGitで落としたんですけどね...)

ネットワーク遅延

ネットワーク遅延したときに、ローディングマークが出ることを確認します。
これはリポジトリパターンとは関係なく、おまけ的な確認のようです。
以下にdelay(2000)を追加します

private fun refreshDataFromNetwork() = viewModelScope.launch {

        try {
            // 遅延処理を追加(サンプルコードはcatch句に書いているが、正常動作しているのでこっちに書く)
            delay(2000)
             val playlist = DevByteNetwork.devbytes.getPlaylist()
            _playlist.postValue(playlist.asDomainModel())

            _eventNetworkError.value = false
            _isNetworkErrorShown.value = false

        } catch (networkError: IOException) {
            // Show a Toast error message and hide the progress bar.
            _eventNetworkError.value = true
        }
    }

viewModelScope.launchとすると、コルーチンが起動するみたいです。
delayは多分コルーチンの処理を止める関数。

コルーチンについて詳しくないので別途勉強が必要ですが、とりあえず非同期処理してるんだな、ぐらいでここは流します。

ちなみにネットワークアクセスのような処理はUIスレッド(メインスレッド)でやると、画面が止まってしまうので、バックグラウンドで非同期で動かす必要があります。

実行結果

画面イメージ

ぐるぐるマークが出るようになりました。

ドメインパッケージ

domain/Models.kt

data class DevByteVideo(val title: String,
                        val description: String,
                        val url: String,
                        val updated: String,
                        val thumbnail: String) {

    val shortDescription: String
        get() = description.smartTruncate(200)
}

kotlinではdata classと書くと、get/setterを省略したBeanクラスを作れるみたいです。(最高だぁ~)
このDevByteVideoクラスが、外部システムからとってきた情報を格納する構造体の役割になりそうです。

ネットワークパッケージ

その名の通り、DTO的なクラスがたくさんあります。
ただのDTOなのでほぼ省略しますが、以下の書き方が参考になったので転記します。

network/DataTransferObjects.kt
fun NetworkVideoContainer.asDomainModel(): List<DevByteVideo> {
    return videos.map {
        DevByteVideo(
                title = it.title,
                description = it.description,
                url = it.url,
                updated = it.updated,
                thumbnail = it.thumbnail)
    }
}

JavaのStreamAPIもこんな感じで書けるんですね。

network/Service.kt
こちらはDAOっぽいクラスです。
Retrofitを使ったHTTPアクセスを行います。
特筆することは特にないので流しますが、UIはLiveDataとReciclerViewを使って描画しますよ~的なことが、学習サイトに書いてありました。

オフラインキャッシュ

今回はHTTPアクセスでデータを取得した後、オフラインキャッシュする設計のようです。
もしかしたら、Androidではこれが一般的なのかもしれません。

キャッシュされたデータを使うことで、同じデータに複数回アクセスするときにHTTPアクセスが不要(HTTP通信は結構時間かかるので)になります。

キャッシュの実現方法にはいろいろありますが、今回はRoomを使います。
RoomはAndroid端末内のローカルDB(Sqlite3)を使うためのライブラリです。

要はローカルDBでデータを永続化しますってことですね。

Roomの依存関係の追加

特筆事項はないですが、一応転記しておきます。

// Room dependency
def room_version = "2.1.0-alpha06"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

データベースオブジェクトの作成

ここではRoom用のDTO的なのを作ります。

database/DatabaseEntities.kt
@Entity
data class DatabaseVideo constructor(
       @PrimaryKey
       val url: String,
       val updated: String,
       val title: String,
       val description: String,
       val thumbnail: String)

/**
 * データベースのDTOからドメインのDTOに変換
 */
fun List<DatabaseVideo>.asDomainModel(): List<DevByteVideo> {
    return map {
        DevByteVideo(
                url = it.url,
                title = it.title,
                description = it.description,
                updated = it.updated,
                thumbnail = it.thumbnail)
    }
}

@EntityのアノテーションはRoomで使うDTOの目印です。
@PrimaryKeyはその名の通りPKに付けます。

あと、List.asDomainModel()の書き方ですが、Kotlinには拡張関数という便利機能があって、継承とかしなくても関数を拡張できるようです。

要はリストインタフェースにasDomainModel()という関数を拡張して使えるようにしたということですね。

ここで、HTTPアクセス側のDTOも拡張関数を追加します。

network/DataTransferObjects.kt
/**
* HTTPアクセスのDTOからデータベースのDTOに変換
*/
fun NetworkVideoContainer.asDatabaseModel(): List<DatabaseVideo> {
   return videos.map {
       DatabaseVideo(
               title = it.title,
               description = it.description,
               url = it.url,
               updated = it.updated,
               thumbnail = it.thumbnail)
   }
}

Roomの実装

database/Room.kt
@Dao
interface VideoDao {
    @Query("select * from databasevideo")
    fun getVideos(): LiveData<List<DatabaseVideo>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertAll( videos: List<DatabaseVideo>)
}

@Database(entities = [DatabaseVideo::class], version = 1)
abstract class VideosDatabase: RoomDatabase() {
   abstract val videoDao: VideoDao
}

private lateinit var INSTANCE: VideosDatabase

fun getDatabase(context: Context): VideosDatabase {
   synchronized(VideosDatabase::class.java) {
       if (!::INSTANCE.isInitialized) {
           INSTANCE = Room.databaseBuilder(context.applicationContext,
                   VideosDatabase::class.java,
                   "videos").build()
       }
   }
   return INSTANCE
}

Dao

@Daoで、RoomのDaoであることの目印を付けます。
いわゆるORMってやつで、@QueryでSELECT文の結果をリターンし、@Insertで@Entityの内容をINSERTするようです。
onConflictでPK被ったときにどうするかを指定できるようで、今回はREPLACEにしています。(おそらくSQLがREPLACE文になる?)

Database

@DatabaseでDatabaseとしての目印を付けます。
entitiesで@Entityを指定します。
versionについてはよくわかりませんが、DBをマイグレーションするためのバージョン管理って感じでしょうか。いまいち仕組みわかってませんが、ここは一旦流します。
あと、クラスはRoomDatabaseを継承する必要がありそうです。

Kotlinは、abstract val で変数を抽象化できるようです。(Javaにもあったっけ?)
多分VideoDaoはDIコンテナみたいな機能で、@Daoアノテーションのインスタンスが勝手に生成されるっぽいです。

その他

database/Room.kt
private lateinit var INSTANCE: VideosDatabase

fun getDatabase(context: Context): VideosDatabase {
    synchronized(VideosDatabase::class.java) {
        if (!::INSTANCE.isInitialized) {
            INSTANCE = Room.databaseBuilder(context.applicationContext,
                    VideosDatabase::class.java,
                    "videos").build()
        }
    }
    return INSTANCE
}

いろいろメモりたいことがあります。
まず、lateinitとは、遅延初期化です。kotlinは基本的にnullを許しませんが、lateinitで遅延初期化を許します。

あと、サラッとトップレベルに関数とか変数とか定義してます。Kotlinではトップレベルに定義できるみたいです。「::INSTANCE」は軽く調べても出てこなかったのですが、トップレベルでのメソッド参照だと思います。

要はシングルトンで、VideosDatabaseインスタンスを作成する機能をここで実現しています。

ここまでが、Roomを使ったオフラインキャッシュの仕組みです。
ここからがリポジトリパターンを適用していくフェーズです。(前置きが長かった)

リポジトリパターン

概要図

イメージ図

要は、Repositoryを仲介させて、ViewModelが直接DBやHTTPのような外部へのアクセスをしないようにする設計です。

今回は、オンラインでデータ取得する処理とオフラインキャッシュからデータを取得する処理があります。
その処理の実現方法は、アプリケーションのメイン処理(ロジック)からすれば、何でもよい(=依存させたくない)ので、Repositoryを仲介させる、というのが多分大きな目的のようです。

あと、Repositoryに限らず、外部へのアクセスのモジュールは、昨今、技術の移り変わりが激しく、変更のかかりやすい部分です。
また、大事なロジックを記述しているモジュールが外部に依存すると、当然テストも外部に依存したテストになってしまいます。

ということで、クリーンアーキテクチャを筆頭に、昨今では大事なロジック部分は、外部に依存させないのが主流な気がします。(クリーンアーキテクチャ的にいうと、この設計だと依存の向きが良くない気がしますが、リポジトリパターンの範囲外ということでしょうか...?)

Repositoryの実装

repository/VideosRepository.kt
class VideosRepository(private val database: VideosDatabase) {

    suspend fun refreshVideos() {
        withContext(Dispatchers.IO) {
            Timber.d("refresh videos is called")
            // Retrofit(Rest ClientのGetメソッド)を使用して、データ取得
            val playlist = DevByteNetwork.devbytes.getPlaylist()
            // Database用のDTOに変換して、インサート処理
            database.videoDao.insertAll(playlist.asDatabaseModel())
        }
    }

    // DatabaseのDTOから、Domainのオブジェクトに変換
    val videos: LiveData<List<DevByteVideo>> = Transformations.map(database.videoDao.getVideos()) {
        it.asDomainModel()
    }
}

refreshVideos()メソッドで、HTTP(REST)でデータ取得して、DBへインサートします。
DBアクセスなので、コルーチンを使った非同期処理です。
suspendとかその辺のお作法はまだよくわかりません。

Transformations.map()はLiveDataでラップされたリストの変換メソッドのようです。(Javaにもあるようですが、知りませんでした)

ViewModelの実装(Repositoryの呼び出し)

viewmodels/DevByteViewModel.kt
private val videosRepository = VideosRepository(getDatabase(application))

val playlist = videosRepository.videos

init {
   refreshDataFromRepository()
}

private fun refreshDataFromRepository() {
   viewModelScope.launch {
       try {
           videosRepository.refreshVideos()
           _eventNetworkError.value = false
           _isNetworkErrorShown.value = false

       } catch (networkError: IOException) {
           // Show a Toast error message and hide the progress bar.
           if(playlist.value.isNullOrEmpty())
               _eventNetworkError.value = true
       }
   }
}

Repository呼び出しのときは、コルーチンによる非同期処理です。
あと、playlist.value.isNullOrEmpty() これ、Javaやってる人だと、え?ってなってしまうと思うのですが、isNullOrEmpty()は、JavaのOptionalのようなの動作をする(KotlinのNullable型の拡張関数)らしいです。

ということで、完成です。

まとめ

  • リポジトリパターンを使う理由
    • オフラインキャッシュの仕組みをViewModelから隠蔽したい
    • ViewModelから外部アクセス(DBやHTTP)を隠蔽したい
  • よくわからない点
    • クリーンアーキテクチャを意識するのであれば、ViewModelはRepositoryに依存するべきではないのでは?
    • リポジトリ関係ないけど、ViewModelにロジック実装するのって微妙じゃね...?
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1