kotlin--綜合運(yùn)用Hilt、Paging3戈轿、Flow凌受、Room、Retrofit思杯、Coil等實(shí)現(xiàn)MVVM架構(gòu)

前面我們使用Java來(lái)運(yùn)用JetPack中的一系列組件胜蛉,又使用kotlin運(yùn)用這些組件實(shí)現(xiàn)了一系列功能:
接著,Jetpack的Paging3中色乾,我們使用的語(yǔ)言是kotlin誊册,相信通過(guò)這些項(xiàng)目的對(duì)比,你就能發(fā)現(xiàn)koltin取代Java的理由了暖璧,kotlin擁有更好的擴(kuò)展性案怯,更高的性能,更簡(jiǎn)潔的代碼澎办,更好的Jetpack組件支持嘲碱,如果你還對(duì)kotlin不熟悉,那么可以查閱我的kotlin專題博客局蚀,在此也要感謝動(dòng)腦學(xué)院Jason老師的辛勤付出麦锯,動(dòng)腦學(xué)院在B站上也有投稿koltin基礎(chǔ)的視頻,通過(guò)視頻可以快速學(xué)習(xí)和上手kotlin
今天來(lái)綜合使用各種組件琅绅,搭建最新MVVM項(xiàng)目框架扶欣,利用Paging3實(shí)現(xiàn)列表功能,Paging3和Paging2一樣,支持?jǐn)?shù)據(jù)庫(kù)緩存

一宵蛀、依賴

主項(xiàng)目gradle中導(dǎo)入hilt插件

    dependencies {
        classpath "com.android.tools.build:gradle:7.0.2"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.20"
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28.1-alpha'
    }

module依賴hilt昆著、kapt插件

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
    id 'dagger.hilt.android.plugin'
}

DataBinding、ViewBinding支持:

    buildFeatures {
        dataBinding = true
        viewBinding = true
    }

kotlin1.5.20使用Hilt編譯會(huì)出現(xiàn)問(wèn)題
Expected @HiltAndroidApp to have a value. Did you forget to apply the Gradle Plugin?
解決方法:

    kapt {
        javacOptions {
            option("-Adagger.hilt.android.internal.disableAndroidSuperclassValidation=true")
        }
    }

依賴各大組件:

    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1'
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation "com.squareup.retrofit2:converter-gson:2.9.0"
    implementation 'com.squareup.okhttp3:logging-interceptor:3.4.1'
    implementation "io.coil-kt:coil:1.1.0"

    def room_version = "2.3.0"
    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    
    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01'
    implementation "androidx.startup:startup-runtime:1.0.0"

    def hilt_version = "2.28-alpha"
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
    def hilt_view_version = "1.0.0-alpha01"
    implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hilt_view_version"
    kapt "androidx.hilt:hilt-compiler:$hilt_view_version"
    
    implementation "androidx.activity:activity-ktx:1.1.0"
    implementation "androidx.fragment:fragment-ktx:1.2.5"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
    implementation 'androidx.paging:paging-runtime-ktx:3.0.0-beta03'

二术陶、Hilt注入

Hilt注解釋義:
  • @HiltAndroidApp:觸發(fā)Hilt的代碼生成
  • @AndroidEntryPoint:創(chuàng)建一個(gè)依賴容器凑懂,該容器遵循Android類的生命周期
  • @Module:告訴Hilt如何提供不同類型的實(shí)例
  • @InstallIn:用來(lái)告訴Hilt這個(gè)模塊會(huì)被安裝到哪個(gè)組件上
  • @Provides:告訴Hilt如何獲取具體實(shí)例
  • @Singleton:?jiǎn)卫?/li>
  • @ViewModelInject:通過(guò)構(gòu)造函數(shù),給ViewModel注入實(shí)例
1.Application注入HiltAndroidApp
@HiltAndroidApp
class APP : Application()

別忘了在Manifest中配置

2.Activity中開始查找注入對(duì)象

使用AndroidEntryPoint注解來(lái)表示梧宫,Hilt開始查找注入對(duì)象

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val binding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
    }
}
3.Hilt注入網(wǎng)絡(luò)模塊

我們準(zhǔn)備使用Retrofit封裝一個(gè)網(wǎng)絡(luò)模塊接谨,需要對(duì)該模塊使用Module注解InstallIn注解綁定到對(duì)應(yīng)Android類的生命周期,顯然整個(gè)APP運(yùn)行過(guò)程中塘匣,我們都要使用網(wǎng)絡(luò)模塊脓豪,所以選擇綁定Application

@InstallIn(ApplicationComponent::class)
@Module
object RetrofitModule {
    
}

提供一個(gè)方法給Hilt獲取Okhttp對(duì)象,此方法為單例忌卤,所以使用Provides和Singleton

{
    private val TAG: String = RetrofitModule.javaClass.simpleName

    @Singleton
    @Provides
    fun getOkHttpClient(): OkHttpClient {
        val interceptor = HttpLoggingInterceptor {
            Log.d(TAG, it)
        }.apply { level = HttpLoggingInterceptor.Level.BODY }
        
        return OkHttpClient.Builder().addInterceptor(interceptor).build()
    }
}

再提供一個(gè)獲取Retrofit的方法:

{
    @Singleton
    @Provides
    fun getRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

完整的網(wǎng)絡(luò)模塊代碼:

const val BASE_URL = "http://192.168.17.114:8080/pagingserver_war/"

@InstallIn(ApplicationComponent::class)
@Module
object RetrofitModule {

    private val TAG: String = RetrofitModule.javaClass.simpleName

    @Singleton
    @Provides
    fun getOkHttpClient(): OkHttpClient {
        val interceptor = HttpLoggingInterceptor {
            Log.d(TAG, it)
        }.apply { level = HttpLoggingInterceptor.Level.BODY }
        
        return OkHttpClient.Builder().addInterceptor(interceptor).build()
    }

    @Singleton
    @Provides
    fun getRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

三扫夜、接口與實(shí)體類

1.根據(jù)接口和接口返回的json數(shù)據(jù)赡若,分別創(chuàng)建API和實(shí)體類

api地址:ikds.do?since=0&pagesize=5
服務(wù)器數(shù)據(jù):

[
    {
        "id":1,
        "title":"扎克·施奈德版正義聯(lián)盟",
        "cover":"https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2634360594.webp",
        "rate":"8.9"
    },
    {
        "id":2,
        "title":"侍神令",
        "cover":"https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2629260713.webp",
        "rate":"5.8"
    },
    {
        "id":3,
        "title":"雙層肉排",
        "cover":"https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2633977758.webp",
        "rate":"6.7"
    },
    {
        "id":4,
        "title":"大地",
        "cover":"https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2628845704.webp",
        "rate":"6.6"
    },
    {
        "id":5,
        "title":"租來(lái)的朋友",
        "cover":"https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2616903233.webp",
        "rate":"6.1"
    }
]

實(shí)體類:

data class MovieItemModel(
    val id: Int,
    val title: String,
    val cover: String,
    val rate: String
)

API接口:

interface MovieService {
    @GET("ikds.do")
    suspend fun getMovieList(
        @Query("since") since: Int,
        @Query("pagesize") pagesize: Int
    ): List<MovieItemModel>
}
2.在網(wǎng)絡(luò)模塊RetrofitModule中新增獲取MovieService的方法
{
    @Singleton
    @Provides
    fun provideMovieService(retrofit: Retrofit): MovieService {
        return retrofit.create(MovieService::class.java)
    }
}

四肃拜、Hilt注入數(shù)據(jù)庫(kù)模塊

1.Room相關(guān)基類

使用Room數(shù)據(jù)庫(kù),首先創(chuàng)建Entity咆课,這邊加了一個(gè)頁(yè)碼的字段:

@Entity
data class MovieEntity(
    @PrimaryKey
    val id: Int,
    val title: String,
    val cover: String,
    val rate: String,
    val page: Int//頁(yè)碼
)

創(chuàng)建Dao棍厂,Room支持返回PagingSource對(duì)象颗味,可以直接和我們的Paging結(jié)合使用了:

@Dao
interface MovieDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(movieList: List<MovieEntity>)

    @Query("SELECT * FROM MovieEntity")
    fun getMovieList(): PagingSource<Int, MovieEntity>

    @Query("DELETE FROM MovieEntity")
    suspend fun clear()
}

定義Database抽象類

@Database(entities = [MovieEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun movieDao(): MovieDao
}
2.Hilt注入數(shù)據(jù)庫(kù)模塊

數(shù)據(jù)庫(kù)模塊同樣需要伴隨應(yīng)用的生命周期,所以還是和Application綁定
提供方法給Hilt獲取AppDatabase牺弹、MovieDao

@InstallIn(ApplicationComponent::class)
@Module
object RoomModule {

    @Singleton
    @Provides
    fun getAppDatabase(application: Application): AppDatabase {
        return Room.databaseBuilder(application, AppDatabase::class.java, "my.db")
            .build()
    }

    @Singleton
    @Provides
    fun provideMovieDao(appDatabase: AppDatabase): MovieDao {
        return appDatabase.movieDao()
    }

}

五浦马、Pager配置

我們有了網(wǎng)絡(luò)模塊,數(shù)據(jù)庫(kù)模塊张漂,接下來(lái)就要實(shí)現(xiàn)配置Pager晶默,PagingSource我們已經(jīng)實(shí)現(xiàn)了從數(shù)據(jù)庫(kù)獲取,現(xiàn)在需要的實(shí)現(xiàn)的是:網(wǎng)絡(luò)數(shù)據(jù)使用RemoteMediator獲取

1.網(wǎng)絡(luò)數(shù)據(jù)獲染樾狻:RemoteMediator

結(jié)合最初的架構(gòu)圖荤胁,RemoteMediator是用于獲取網(wǎng)絡(luò)數(shù)據(jù),并將數(shù)據(jù)存入數(shù)據(jù)庫(kù)屎债,我們就可以從數(shù)據(jù)庫(kù)獲取PagingSource,傳遞給后續(xù)的Pager

@OptIn(ExperimentalPagingApi::class)
class MovieRemoteMediator(
    private val api: MovieService,
    private val appDatabase: AppDatabase
) : RemoteMediator<Int, MovieEntity>() {
    
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, MovieEntity>
    ): MediatorResult {
        TODO("Not yet implemented")
    }
    
}

load函數(shù)先放一邊垢油,先來(lái)實(shí)現(xiàn)架構(gòu)中其他模塊

2.對(duì)ViewModel暴露獲取數(shù)據(jù)接口:Repository

定義一個(gè)Repository接口獲取Flow<PagingData<T>>數(shù)據(jù)盆驹,T應(yīng)該為MovieItemModel,因?yàn)閷?duì)外(ViewModel)而言滩愁,使用的都是MovieItemModel網(wǎng)絡(luò)對(duì)象躯喇,對(duì)內(nèi)使用的才是MovieEntity數(shù)據(jù)庫(kù)對(duì)象

interface Repository<T : Any> {
    fun fetchList(): Flow<PagingData<T>>
}

實(shí)現(xiàn)類,使用MovieItemModel作為泛型類型,并返回Pager的Flow:

class MovieRepositoryImpl(
    private val api: MovieService,
    private val appDatabase: AppDatabase
) : Repository<MovieItemModel> {

    override fun fetchList(): Flow<PagingData<MovieItemModel>> {
        val pageSize = 10

        return Pager(
            config = PagingConfig(
                initialLoadSize = pageSize * 2,
                pageSize = pageSize,
                prefetchDistance = 1
            ),
            remoteMediator = MovieRemoteMediator(api, appDatabase)
        ) {
            appDatabase.movieDao().getMovieList()
        }.flow.flowOn(Dispatchers.IO).map { 
            
        }
    }

}

編譯器上可以看到map中的it對(duì)象為Paging<MovieEntity>類型的廉丽,因?yàn)槲覀?strong>MovieDao返回的是一個(gè)PagingSource<Int, MovieEntity>對(duì)象倦微,所以需要把MovieEntity轉(zhuǎn)換為MovieItemModel

3.Data Mapper

Data Mapper廣泛應(yīng)用于MyBatis,Data Mapper將數(shù)據(jù)源的Model(MovieEntity)轉(zhuǎn)換為頁(yè)面顯示Model(MovieItemModel)正压,兩者分開的原因就是為了Model層和View層進(jìn)一步解耦

定義統(tǒng)一轉(zhuǎn)換接口:

interface Mapper<I, O> {
    fun map(input: I): O
}

針對(duì)MovieEntity和MovieItemModel實(shí)現(xiàn)接口

class MovieEntity2ItemModelMapper : Mapper<MovieEntity, MovieItemModel> {
    override fun map(input: MovieEntity): MovieItemModel {
        return input.run {
            MovieItemModel(
                id = id,
                title = title,
                cover = cover,
                rate = rate
            )
        }
    }
}
4.利用Mapper對(duì)Repository轉(zhuǎn)換

有了Mapper后欣福,就可以將2.中我們的MovieEntity轉(zhuǎn)換為MovieItemModel了

class MovieRepositoryImpl(
    private val api: MovieService,
    private val appDatabase: AppDatabase,
    private val mapper: MovieEntity2ItemModelMapper
) : Repository<MovieItemModel> {

    @OptIn(ExperimentalPagingApi::class)
    override fun fetchList(): Flow<PagingData<MovieItemModel>> {
        val pageSize = 10

        return Pager(
            config = PagingConfig(
                initialLoadSize = pageSize * 2,
                pageSize = pageSize,
                prefetchDistance = 1
            ),
            remoteMediator = MovieRemoteMediator(api, appDatabase)
        ) {
            appDatabase.movieDao().getMovieList()
        }.flow.flowOn(Dispatchers.IO).map { pagingData ->
            pagingData.map { mapper.map(it) }
        }
    }

}
5.Hilt注入Repository

Repository的生命周期并不是伴隨應(yīng)用的,而是伴隨Activity焦履,所以安裝到ActivityComponent
同樣方法也不是單例的拓劝,而是根據(jù)Activity,使用ActivityScoped注解

@InstallIn(ActivityComponent::class)
@Module
object RepositoryModule {

    @ActivityScoped
    @Provides
    fun provideMovieRepository(
        api: MovieService,
        appDatabase: AppDatabase
    ): MovieRepositoryImpl {
        return MovieRepositoryImpl(api, appDatabase, MovieEntity2ItemModelMapper())
    }

}

六嘉裤、ViewModel

Model層的架構(gòu)搭建完畢后郑临,我們需要ViewModel層與Model層作數(shù)據(jù)交互

Hilt注入ViewModel構(gòu)造函數(shù)

ViewModel中需要Repository對(duì)象作為屬性,而Hilt支持使用ViewModelInject注解給ViewModel構(gòu)造函數(shù)注入

class MovieViewModel @ViewModelInject constructor(
    private val repository: MovieRepositoryImpl
) : ViewModel() {
    val data = repository.fetchList().cachedIn(viewModelScope).asLiveData()
}

七屑宠、Adapter與Coil

ViewModel完成后厢洞,接下來(lái)需要RecyclerView的Adapter,這塊和之前的Paggin3一樣

1.布局文件
<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingVertical="10dip">


        <ImageView
            android:id="@+id/imageView"
            android:layout_width="100dip"
            android:layout_height="100dip"
            app:image="@{movie.cover}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/guideline2"
            app:layout_constraintHorizontal_bias="0.432"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.054"
            tools:srcCompat="@tools:sample/avatars" />

        <TextView
            android:id="@+id/textViewTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{movie.title}"
            android:textSize="16sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="@+id/guideline"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.255"
            tools:text="泰坦尼克號(hào)" />

        <TextView
            android:id="@+id/textViewRate"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="24dp"
            android:text="@{movie.rate}"
            android:textSize="16sp"
            app:layout_constraintStart_toStartOf="@+id/guideline"
            app:layout_constraintTop_toBottomOf="@+id/textViewTitle"
            tools:text="評(píng)分:8.9分" />

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/guideline2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_percent="0.4" />

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_percent="0.5" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    <data>

        <variable
            name="movie"
            type="com.aruba.mvvmapplication.model.MovieItemModel" />
    </data>
</layout>
2.BindingAdapter

使用BindingAdapter自定義一個(gè)image屬性
這邊選用Coil作為圖片加載框架典奉,Coil相較于其他框架擁有更好的性能躺翻、更小的體積、易用性秋柄、結(jié)合了協(xié)程获枝、androidx等最新技術(shù)、還擁有緩存骇笔、動(dòng)態(tài)采樣省店、加載暫停/終止等功能

@BindingAdapter("image")
fun setImage(imageView: ImageView, imageUrl: String) {
    imageView.load(imageUrl) {
        placeholder(R.drawable.ic_launcher_foreground)//占位圖
        crossfade(true)//淡入淡出
    }
}
3.Adapter實(shí)現(xiàn)

使用ViewDataBinding作為屬性,定義一個(gè)基類ViewHolder

class BindingViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)

Adapter繼承PagingDataAdapter笨触,并傳入一個(gè)DiffUtil.ItemCallback

class MoviePagingAdapter : PagingDataAdapter<MovieItemModel, BindingViewHolder>(
    object : DiffUtil.ItemCallback<MovieItemModel>() {
        override fun areItemsTheSame(oldItem: MovieItemModel, newItem: MovieItemModel): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: MovieItemModel, newItem: MovieItemModel): Boolean {
            return oldItem == newItem
        }
    }
) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder {
        val binding = ItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return BindingViewHolder(binding)
    }

    override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {
        if (getItem(position) != null)
            (holder.binding as ItemBinding).movie = getItem(position)
    }

}
4.為RecyclerView添加擴(kuò)展函數(shù)

為了后續(xù)Paging的使用懦傍,為RecyclerView添加設(shè)置Adapter和liveData的擴(kuò)展函數(shù)

fun <VH : RecyclerView.ViewHolder, T : Any> RecyclerView.setPagingAdapter(
    owner: LifecycleOwner,
    adapter: PagingDataAdapter<T, VH>,
    liveData: LiveData<PagingData<T>>
) {
    liveData.observe(owner) {
        adapter.submitData(owner.lifecycle, it)
    }
}

Activity的代碼如下:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val binding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }
    private val viewModel: MovieViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        binding.recyclerview.setPagingAdapter(
            owner = this,
            adapter = MoviePagingAdapter(),
            liveData = viewModel.data
        )
    }
}

八、實(shí)現(xiàn)RemoteMediator

之前未實(shí)現(xiàn)load函數(shù)的代碼:

@OptIn(ExperimentalPagingApi::class)
class MovieRemoteMediator(
    private val api: MovieService,
    private val appDatabase: AppDatabase
) : RemoteMediator<Int, MovieEntity>() {
    
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, MovieEntity>
    ): MediatorResult {
        TODO("Not yet implemented")
    }
    
}
1.MediatorResult

load函數(shù)需要一個(gè)MediatorResult類型的返回值芦劣,MediatorResult有三種返回參數(shù):

  • MediatorResult.Error(e):出現(xiàn)錯(cuò)誤
  • MediatorResult.Success(endOfPaginationReached = false):請(qǐng)求成功且有數(shù)據(jù)(還有下一頁(yè))
  • MediatorResult.Success(endOfPaginationReached = true):請(qǐng)求成功但沒有數(shù)據(jù)(到底了)

返回MediatorResult.Success粗俱,pager就會(huì)從數(shù)據(jù)庫(kù)中拿數(shù)據(jù),load函數(shù)初步實(shí)現(xiàn):

{
        try {
            //1.判斷l(xiāng)oadType

            //2.請(qǐng)求網(wǎng)絡(luò)分頁(yè)數(shù)據(jù)

            //3.存入數(shù)據(jù)庫(kù)

            val endOfPaginationReached = true
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (e: Exception) {
            return MediatorResult.Error(e)
        }
}
2.LoadType

LoadType為枚舉類虚吟,有三個(gè)對(duì)象:

  • Refresh:首次加載數(shù)據(jù)和調(diào)用PagingDataAdapter.refresh()時(shí)觸發(fā)
  • Append:加載更多數(shù)據(jù)時(shí)觸發(fā)
  • Prepend:在列表頭部添加數(shù)據(jù)時(shí)觸發(fā)寸认,Refresh觸發(fā)時(shí)也會(huì)觸發(fā)

第一步就需要判斷LoadType的狀態(tài),如果是Refresh串慰,那么數(shù)據(jù)庫(kù)中沒有數(shù)據(jù)偏塞,就要從網(wǎng)絡(luò)獲取數(shù)據(jù),Refresh狀態(tài)下load函數(shù)執(zhí)行完畢后會(huì)自動(dòng)再次調(diào)用load函數(shù)邦鲫,此時(shí)的LoadType為Append灸叼,此時(shí)數(shù)據(jù)庫(kù)中有數(shù)據(jù)了神汹,直接返回Success通知Pager可以從數(shù)據(jù)庫(kù)取數(shù)據(jù)了

{
        try {
            //1.判斷l(xiāng)oadType
            val pageKey = when (loadType) {
                //首次加載
                LoadType.REFRESH -> null
                //REFRESH之后還會(huì)調(diào)用load(REFRESH時(shí)數(shù)據(jù)庫(kù)中沒有數(shù)據(jù)),來(lái)加載開頭的數(shù)據(jù)古今,直接返回成功就可以了
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = false)
                //加載更多
                LoadType.APPEND -> {

                }
            }
            
            //2.請(qǐng)求網(wǎng)絡(luò)分頁(yè)數(shù)據(jù)
            val page = pageKey ?: 0
            
            //3.存入數(shù)據(jù)庫(kù)

            val endOfPaginationReached = true
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (e: Exception) {
            return MediatorResult.Error(e)
        }
}
3.PagingState

對(duì)于下一頁(yè)的數(shù)據(jù)屁魏,則要使用PagingState獲取了,PagingState分為兩部分組成:

  • pages:上一頁(yè)的數(shù)據(jù)捉腥,主要用來(lái)獲取最后一個(gè)item氓拼,作為下一頁(yè)的開始位置
  • config:配置Pager時(shí)的PagingConfig,可以獲取到pageSize等一系列初始化配置的值

如果上一頁(yè)最后一個(gè)item為空但狭,那么表示列表加載到底了披诗,否則獲取到需要加載的當(dāng)前page

{
                //加載更多
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(
                        endOfPaginationReached = true
                    )
                    lastItem.page//返回當(dāng)前頁(yè)
                }
}
4.網(wǎng)絡(luò)獲取數(shù)據(jù)和存入數(shù)據(jù)庫(kù)

接下來(lái)就是從網(wǎng)絡(luò)獲取數(shù)據(jù)了:

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, MovieEntity>
    ): MediatorResult {
        try {
            //1.判斷l(xiāng)oadType
            val pageKey = when (loadType) {
                //首次加載
                LoadType.REFRESH -> null
                //REFRESH之后還會(huì)調(diào)用load(REFRESH時(shí)數(shù)據(jù)庫(kù)中沒有數(shù)據(jù)),來(lái)加載開頭的數(shù)據(jù)立磁,直接返回成功就可以了
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = false)
                //加載更多
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(
                        endOfPaginationReached = true
                    )
                    lastItem.page//返回當(dāng)前頁(yè)
                }
            }

            //2.請(qǐng)求網(wǎng)絡(luò)分頁(yè)數(shù)據(jù)
            val page = pageKey ?: 0
            val result = api.getMovieList(
                page * state.config.pageSize,
                state.config.pageSize
            )

            //3.存入數(shù)據(jù)庫(kù)

            val endOfPaginationReached = true
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (e: Exception) {
            return MediatorResult.Error(e)
        }
    }

服務(wù)器對(duì)象轉(zhuǎn)換為本地?cái)?shù)據(jù)庫(kù)對(duì)象后呈队,存入數(shù)據(jù)庫(kù),完整RemoteMediator代碼:

@OptIn(ExperimentalPagingApi::class)
class MovieRemoteMediator(
    private val api: MovieService,
    private val appDatabase: AppDatabase
) : RemoteMediator<Int, MovieEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, MovieEntity>
    ): MediatorResult {
        try {
            //1.判斷l(xiāng)oadType
            val pageKey = when (loadType) {
                //首次加載
                LoadType.REFRESH -> null
                //REFRESH之后還會(huì)調(diào)用load(REFRESH時(shí)數(shù)據(jù)庫(kù)中沒有數(shù)據(jù))唱歧,來(lái)加載開頭的數(shù)據(jù)宪摧,直接返回成功就可以了
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = false)
                //加載更多
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(
                        endOfPaginationReached = true
                    )
                    lastItem.page//返回當(dāng)前頁(yè)
                }
            }

            //2.請(qǐng)求網(wǎng)絡(luò)分頁(yè)數(shù)據(jù)
            val page = pageKey ?: 0
            val result = api.getMovieList(
                page * state.config.pageSize,
                state.config.pageSize
            )

            //服務(wù)器對(duì)象轉(zhuǎn)換為本地?cái)?shù)據(jù)庫(kù)對(duì)象
            val entity = result.map {
                MovieEntity(
                    id = it.id,
                    title = it.title,
                    cover = it.cover,
                    rate = it.rate,
                    page = page + 1
                )
            }
            //3.存入數(shù)據(jù)庫(kù)
            val movieDao = appDatabase.movieDao()
            appDatabase.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    movieDao.clear()
                }

                movieDao.insert(entity)
            }

            val endOfPaginationReached = result.isEmpty()
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (e: Exception) {
            return MediatorResult.Error(e)
        }
    }

}

運(yùn)行后的效果:


聯(lián)動(dòng).gif

九、刷新

1.上拉刷新颅崩、重試按鈕几于、錯(cuò)誤信息

上拉刷新、重試按鈕沿后、錯(cuò)誤信息布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="10dp"
    android:layout_marginBottom="20dp"
    android:gravity="center"
    android:orientation="vertical"
    android:paddingBottom="20dp">

    <Button
        android:id="@+id/retryButton"
        style="@style/Widget.AppCompat.Button.Colored"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/footer_retry"
        android:textColor="@android:color/background_dark" />

    <ProgressBar
        android:id="@+id/progress"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/errorMsg"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@android:color/background_dark"
        tools:text="連接超時(shí)"/>

</LinearLayout>

之前我們使用Paging的LoadStateAdapter沿彭,直接設(shè)置到PagingDataAdapter上就可以了,刷新對(duì)應(yīng)的ViewHolder如下:

class NetWorkStateItemViewHolder(
    private val binding: NetworkStateItemBinding,
    val retryCallback: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    fun bindData(data: LoadState){
        binding.apply {
            // 正在加載尖滚,顯示進(jìn)度條
            progress.isVisible = data is LoadState.Loading
            // 加載失敗喉刘,顯示并點(diǎn)擊重試按鈕
            retryButton.isVisible = data is LoadState.Error
            retryButton.setOnClickListener { retryCallback() }
            // 加載失敗顯示錯(cuò)誤原因
            errorMsg.isVisible = !(data as? LoadState.Error)?.error?.message.isNullOrBlank()
            errorMsg.text = (data as? LoadState.Error)?.error?.message
        }
    }

}

inline var View.isVisible: Boolean
    get() = visibility == View.VISIBLE
    set(value) {
        visibility = if (value) View.VISIBLE else View.GONE
    }

Adapter代碼:

class FooterAdapter(
    val adapter: MoviePagingAdapter
) : LoadStateAdapter<NetWorkStateItemViewHolder>() {

    override fun onBindViewHolder(holder: NetWorkStateItemViewHolder, loadState: LoadState) {
        //水平居中
        val params = holder.itemView.layoutParams
        if (params is StaggeredGridLayoutManager.LayoutParams) {
            params.isFullSpan = true
        }
        holder.bindData(loadState)
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        loadState: LoadState
    ): NetWorkStateItemViewHolder {
        val binding =
            NetworkStateItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return NetWorkStateItemViewHolder(binding) { adapter.retry() }
    }
}

Activity中配置下PagingDataAdapter,并為RecyclerView設(shè)置ConcatAdapter漆弄,一定要設(shè)置成withLoadStateFooter函數(shù)返回的Adapter睦裳,否則不會(huì)有效果!撼唾!

        val adapter = MoviePagingAdapter()

        binding.recyclerview.adapter = adapter
            .run { withLoadStateFooter(FooterAdapter(this)) }
2.下拉刷新

下拉刷新和之前也是相同的廉邑,布局中嵌套一個(gè)SwipeRefreshLayout

<?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=".activity.MainActivity">

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/refreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
            app:spanCount="2" />

    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

Activity中對(duì)PagingDataAdapter的loadState進(jìn)行監(jiān)聽:

        lifecycleScope.launchWhenCreated {
            //監(jiān)聽adapter狀態(tài)
            adapter.loadStateFlow.collect {
                //根據(jù)刷新狀態(tài)來(lái)通知swiprefreshLayout是否刷新完畢
                binding.refreshLayout.isRefreshing = it.refresh is LoadState.Loading
            }
        }

十、App Starup實(shí)現(xiàn)無(wú)網(wǎng)絡(luò)數(shù)據(jù)組件初始化

RemoteMediator中可以在無(wú)網(wǎng)絡(luò)時(shí)從數(shù)據(jù)庫(kù)獲取數(shù)據(jù)倒谷,所以load函數(shù)中我們還需要對(duì)網(wǎng)絡(luò)狀態(tài)進(jìn)行判斷蛛蒙,無(wú)網(wǎng)絡(luò)時(shí),直接返回Success

1.獲取網(wǎng)絡(luò)狀態(tài)的擴(kuò)展函數(shù)

定義一個(gè)擴(kuò)展函數(shù)用來(lái)獲取網(wǎng)絡(luò)狀態(tài):

@Suppress("DEPRECATION")
@SuppressLint("MissingPermission")
fun Context.isConnectedNetwork(): Boolean = run {
    val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    val activeNetwork: NetworkInfo? = cm.activeNetworkInfo
    activeNetwork?.isConnectedOrConnecting == true
}

Manifest中不要忘了加權(quán)限

2.新建幫助類渤愁,初始化Context
object AppHelper {
    lateinit var mContext: Context

    fun init(context: Context) {
        this.mContext = context
    }
}
3.RemoteMediator中判斷網(wǎng)絡(luò)狀態(tài)并返回
            //無(wú)網(wǎng)絡(luò)從本地?cái)?shù)據(jù)庫(kù)獲取數(shù)據(jù)
            if (!AppHelper.mContext.isConnectedNetwork()) {
                return MediatorResult.Success(endOfPaginationReached = false)
            }

此時(shí)AppHelper的init函數(shù)還沒有調(diào)用

4.App Starup
image.png

App Starup是JetPack的新成員宇驾,提供了在App啟動(dòng)時(shí)初始化組件簡(jiǎn)單、高效的方法猴伶,還可以指定初始化順序,我們新建一個(gè)類繼承于Initializer

class AppInitializer : Initializer<Unit> {

    override fun create(context: Context) {
        AppHelper.init(context)
    }

    //按順序執(zhí)行初始化
    override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf()
}

最后還需要在Manifest中注冊(cè):

        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false"
            tools:node="merge">
            <meta-data
                android:name="com.aruba.mvvmapplication.init.AppInitializer"
                android:value="androidx.startup" />
        </provider>

最終效果:


項(xiàng)目地址:https://gitee.com/aruba/mvvmapplication.git
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市他挎,隨后出現(xiàn)的幾起案子筝尾,更是在濱河造成了極大的恐慌,老刑警劉巖办桨,帶你破解...
    沈念sama閱讀 221,406評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件筹淫,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡呢撞,警方通過(guò)查閱死者的電腦和手機(jī)损姜,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,395評(píng)論 3 398
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)殊霞,“玉大人摧阅,你說(shuō)我怎么就攤上這事”炼祝” “怎么了棒卷?”我有些...
    開封第一講書人閱讀 167,815評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)祝钢。 經(jīng)常有香客問(wèn)我比规,道長(zhǎng),這世上最難降的妖魔是什么拦英? 我笑而不...
    開封第一講書人閱讀 59,537評(píng)論 1 296
  • 正文 為了忘掉前任蜒什,我火速辦了婚禮,結(jié)果婚禮上疤估,老公的妹妹穿的比我還像新娘灾常。我一直安慰自己,他們只是感情好做裙,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,536評(píng)論 6 397
  • 文/花漫 我一把揭開白布岗憋。 她就那樣靜靜地躺著,像睡著了一般锚贱。 火紅的嫁衣襯著肌膚如雪仔戈。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,184評(píng)論 1 308
  • 那天拧廊,我揣著相機(jī)與錄音监徘,去河邊找鬼。 笑死吧碾,一個(gè)胖子當(dāng)著我的面吹牛凰盔,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播倦春,決...
    沈念sama閱讀 40,776評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼户敬,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼落剪!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起尿庐,我...
    開封第一講書人閱讀 39,668評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤忠怖,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后抄瑟,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體凡泣,經(jīng)...
    沈念sama閱讀 46,212評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,299評(píng)論 3 340
  • 正文 我和宋清朗相戀三年皮假,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了鞋拟。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,438評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡惹资,死狀恐怖贺纲,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情布轿,我是刑警寧澤哮笆,帶...
    沈念sama閱讀 36,128評(píng)論 5 349
  • 正文 年R本政府宣布,位于F島的核電站汰扭,受9級(jí)特大地震影響稠肘,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜萝毛,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,807評(píng)論 3 333
  • 文/蒙蒙 一项阴、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧笆包,春花似錦环揽、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,279評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至巴粪,卻和暖如春通今,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背肛根。 一陣腳步聲響...
    開封第一講書人閱讀 33,395評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工辫塌, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人派哲。 一個(gè)月前我還...
    沈念sama閱讀 48,827評(píng)論 3 376
  • 正文 我出身青樓臼氨,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親芭届。 傳聞我的和親對(duì)象是個(gè)殘疾皇子储矩,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,446評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容