[Cookie Run Cookie情報アプリケーションの作成#4]プロジェクト図面の追加とテスト(1)


前の記事で確認したライブラリを追加しましょう.

プロジェクトの開始



Empty Activityで始めます안드로이드 8からバックグラウンド動作が変わったのでMINSdkを28に設定しました

Hilt


まずはhealtそのものを追加しましょう

Project build.gradle

buildscript {
	dependencies {
    	...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.40.1'
    }
}

Module build.gradle

// Module build.gradle
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
...
dependencies {
  // Hilt
  implementation "com.google.dagger:hilt-android:2.40.1"
  kapt "com.google.dagger:hilt-android-compiler:2.40.1"
}
ヒルトを使用するには、アプリケーションに@HiltAndroidAppアシスタントを追加するだけです.現在アプリケーションクラスはありませんので、MainActivityのある場所にLaunchedApplicationクラスを作成します.

LaunchedApplication.kt

package com.study.cookie

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class LaunchedApplication: Application() {

}

AndroidManifest.xml

...
<application
	android:name=".LaunchedApplication"
	...

MainActivity.kt

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

LifeCycle & ViewModel


Project build.gradle

dependencies {
	...
    // ktx
    implementation 'androidx.fragment:fragment-ktx:1.4.0'

    // LifeCycle
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"

    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
    
    // Hilt
    implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03'
    kapt 'androidx.hilt:hilt-compiler:1.0.0'
}
Ktxライブラリが追加されました.これはコートリンデリー格子であるため、by viewModels()はView Modelに簡単にインポートできます.コードを作成して、正しく適用されているかどうかを見てみましょう.これらのコードは、ライブラリチェック後にすべて削除されるため、どこでも作成できます.MainActivity.ktの下に全部書きました.
package com.study.cookie

import android.os.Bundle
import android.util.Log
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        lifecycleScope.launchWhenCreated {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.count.collect {
                    Log.d("MainActivity", "$it")
                }
            }
        }
    }
}



@HiltViewModel
class MainViewModel @Inject constructor(
    testRepository: TestRepository
): ViewModel() {
    private val _count = MutableStateFlow<Int>(0)
    val count = _count.asStateFlow()

    init {
        viewModelScope.launch {
            launch {
                val number = testRepository.fetchCount()
                _count.value = number
            }
            launch {
                while (true) {
                    delay(500L)
                    _count.value++
                }
            }
        }
    }
}

class TestRepository {
    suspend fun fetchCount(): Int {
        delay(2000L)
        return 1000
    }
}

@Module
@InstallIn(SingletonComponent::class)
class TestModule {

    @Singleton
    @Provides
    fun provideTestRepository(): TestRepository {
        return TestRepository()
    }
}

Hilt説明


まず、HiltにViewModelを書き込むには、クラスに@HiltViewModelを貼り付けるだけです.ViewModelはTestRepositoryに依存する.依存関係をHiltに報告する必要があるため、constructor@Injectの説明を追加することができる.
しかし、HiltはこのTestRepositoryをどこから取得するか分からず、このとき利用できるのは@Moduleです.
その後、InstallInを使用してスキャンを行い、@Singletonおよび@ProvidesのprovideTestRepository()関数を定義し、InstallInの1トンの範囲内でTestRepositoryが必要な場所にDIを割り当てることができる.

コード動作の説明


これで、構築後にアプリケーションを実行すると、Hiltは依存関係に応じて自動的にDIを実行します.MainActivityでby viewModels()を使用してViewModelを作成すると、initセクションでTestRepositoryのfetchCountが取得されます.この関数はsuspend funであるため停止します.
別のサブルーチンは0.5秒ごとにcount値を増加します.MainActivityはLifecycleScope上でcollectを実行するため、0.5秒ごとにログをチェックできます.次の2秒以内にfetchCount関数がresumedの場合、count値は1000に変更されます.
このコードに注意すべき点はrepeatOnLifecycleです.StateFlowはエラーライブラリなので、アンドロイドのライフサイクルはわかりません.つまり、Androidバックグラウンド状態に入ってもcollectを運転し続けます.repeatOnLifecycleでは、バックグラウンドでスレッドを停止し、再起動時にスレッドを再起動できます.

Retrofit2 && Moshi


Project build.gradle

@Module
@InstallIn(SingletonComponent::class)
dependencies {
    ...
    // Retrofit2
    implementation "com.squareup.retrofit2:retrofit:2.9.0"

    // Moshi
    implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
}
これはRetrofit2変換器で、デフォルトのMoshiとJsonデータをdataclassに変換できます.ここでは通常RxJava3 Adapterモジュールを一緒に使用する場合もありますが、今回はCoroutine (suspend fun)を使用します.
FastAPIを使用したAPIサーバの作成で作成したAPIサーバを飛ばし、コードを記述しましょう.
class TestModule {
	...
    @Singleton
    @Provides
    @Named("localhost")
    fun provideLocalhost(): String {
        return "192.168.0.7"
    }

    @Singleton
    @Provides
    @Named("baseUrl")
    fun provideBaseUrl(
        @Named("localhost") localhost: String
    ): String {
        return "http://$localhost:8000"
    }

    @Singleton
    @Provides
    fun provideMoshiConverterFactory(): MoshiConverterFactory {
        return MoshiConverterFactory.create()
    }

    @Singleton
    @Provides
    fun provideRetrofit(
        moshiConverterFactory: MoshiConverterFactory,
        @Named("baseUrl") baseUrl: String
    ): Retrofit {
        return Retrofit.Builder()
            .addConverterFactory(moshiConverterFactory)
            .baseUrl(baseUrl)
            .build()
    }

    @Singleton
    @Provides
    fun provideCookieService(retrofit: Retrofit): CookieService {
        return retrofit.create(CookieService::class.java)
    }
}
原因(シミュレータのlocalhostが違うかもしれません)localhostを使用すると、apiを要求できないという問題が発生し、内部IPを使用して操作しました.Hiltを使用すると、@Namedとして動作するDIを指定できますが、このプロジェクトは現在イントラネットを使用しているため、再接続(コンピュータの再起動)のたびにIPが変化するため、分離が必要です.
data class CookieInfoList(
    @field:Json(name = "list")
    val cookieList: List<CookieInfo>,
    @field:Json(name = "last")
    val last: Int?,
    @field:Json(name = "next")
    val next: Boolean
)

data class CookieInfo(
    @field:Json(name = "cookie_id")
    val cookieId: Int,
    @field:Json(name = "cookie_name")
    val cookieName: String,
    @field:Json(name = "cookie_image")
    val cookieImage: String
)

interface CookieService {
    @GET("/cookie/v1/cookie_id/list")
    suspend fun getCookieInfoList(
        @Query("start") start: Int,
        @Query("length") length: Int
    ): CookieInfoList
}
その後、データモデルが設定され、@Get宣言およびsuspend funをcoutionとして使用する場合、RxJavaまたはenqueueを使用すると、単一のまたはCallを非表示にすることなく、すぐに値を使用できます(suspend funは停止可能です).
viewModelScope.launch {
    launch {
        while (true) {
            delay(1000L)
            cookieList.value.last?.let {
                val fetchCookieList = testRepository.fetchCookieList(
                    it,
                    length = 4
                )

                _cookieList.value = fetchCookieList
            }
        }
    }
}
後でコード・インスタンスでデータを通信することで、コールバックを必要とせずに同期のようにコードを記述できます.ここで、viewModelScope.launchはデフォルトでMainThreadであるため、Retrofit 2内部でIOとみなされるため、withContext(Dispatchers.IO)のようなスレッド切り替えが必要であると考えられます.詳細については、TaewhanのCo定例+Retrofit 2ブログを参照してください.

Coil


Project build.gradle

dependencies {
	...
    // Coil
    implementation "io.coil-kt:coil:1.4.0"
}
// MainActivity.kt
viewModel.cookieList.collect { data ->
    data.cookieList.forEach {
        binding.imageView.load(
            uri = uri	
        )
    }
}
内部では、CoolはGlideよりも軽い画像ライブラリです.ImageViewの拡張関数は、.loadの関数を提供し、画像を簡単に読み込むことができます.

Navigation && SafeArgs


Project build.gradle

// Module build.gradle
buildscript {
	dependencies {
    	...
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
    }
}

Project build.gradle

// Module build.gradle
plugin {
    ....
    id 'androidx.navigation.safeargs.kotlin'
}
...
dependencies {
    // Navigation
    implementation "androidx.navigation:navigation-fragment-ktx:2.3.5"
    implementation "androidx.navigation:navigation-ui-ktx:2.3.5"
}
Navigation Settingsから、古いコードをすべて削除して続行します.

MainActivity.kt

 package com.study.cookie

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}
Navigationライブラリを使用して、アクティブなN分割構造を作成します.MainActivity mainに移動します.xmlをバインドします.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"

        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>
activity_main.xmlファイルにはFragmentContainerViewビューが1つしかありません.重要な点はnav graphであり、以下に説明を添付する.

fragment_first.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="fragment_first"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

fragment_second.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="fragment_second"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout
まず画面切り替えが必要なカットを2つ作ります両者を区別するために、TextViewにxml名を追加しました.

nav_graph.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_graph"
    app:startDestination="@id/firstFragment">

    <fragment
        android:id="@+id/firstFragment"
        android:name="com.study.cookie.FirstFragment"
        android:label="FirstFragment" >
        <action
            android:id="@+id/action_firstFragment_to_secondFragment"
            app:destination="@id/secondFragment" />
    </fragment>
    <fragment
        android:id="@+id/secondFragment"
        android:name="com.study.cookie.SecondFragment"
        android:label="SecondFragment" >
        <argument
            android:name="message"
            app:argType="string" />
        <argument
            android:name="something"
            app:argType="integer" />
    </fragment>
</navigation>

Navigationライブラリには直感的なルーティング関係が表示されます.矢印を使用して分割間の画面切り替えを定義できます.xmlにはaction_firstFragment_to_secondFragmentが追加されています.右側にはArcgumentsとして2つの変数(メッセージ:String、something:Int)があります.

FirstFragment

@AndroidEntryPoint
class FirstFragment : Fragment() {
    private lateinit var binding: FragmentFirstBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentFirstBinding.inflate(inflater, container, false)

        binding.textView.setOnClickListener {
            val directions =
                FirstFragmentDirections.actionFirstFragmentToSecondFragment(
                    message = "navigate",
                    something = 10
                )

            it.findNavController().navigate(directions)
        }

        return binding.root
    }
}
Jetpack Navigationライブラリを追加すると、~Directionsが自動的に生成され、Nav Graphで定義されている移動可能なページが表示されます.ここでは、別のセキュリティ・リポジトリをインストールしているので、移動するにはmessageとsomethingを入力する必要があります.

SecondFragment

package com.study.cookie

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.navArgs
import com.study.cookie.databinding.FragmentSecondBinding
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class SecondFragment : Fragment() {
    private lateinit var binding: FragmentSecondBinding
    private val args: SecondFragmentArgs by navArgs()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentSecondBinding.inflate(inflater, container, false)
        binding.textView.text = args.message

        return binding.root
    }
}
受信側では、navArgs<SecondFragmentArgs> delegateにメッセージとsomething変数が表示されます.SafeArgsがない場合は、実行時にargs["message"]でチェックする必要がありますが、SafeArgsライブラリを使用してコンパイル時にデータモデルを作成しているため、分割間で安全にデータを転送できます.

Timber


使用するすべてのライブラリ設定が完了しました.(後でテストライブラリを追加)
これで、以前に作成したコードをすべて削除し、プロジェクトを開始します.

Project build.gradle

dependencies {
    ...
    implementation 'com.jakewharton.timber:timber:5.0.1'
}

LaunchedApplication.kt

package com.study.cookie

import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber

@HiltAndroidApp
class LaunchedApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        Timber.plant(Timber.DebugTree())
    }
}

MainActivity.kt

package com.study.cookie

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        Timber.d("Hello World!")
    }
}