kotlin--综合运用Hilt、Paging3、Flow、Room、Retrofit、Coil等实现MVVM架构

2023-05-16

前面我们使用Java来运用JetPack中的一系列组件,又使用kotlin运用这些组件实现了一系列功能:

  • kotlin--Flow文件下载
  • kotlin--Flow结合Room运用
  • kotlin--Flow结合retrofit运用
  • kotlin--StateFlow运用
  • kotlin--SharedFlow运用

接着,Jetpack的Paging3中,我们使用的语言是kotlin,相信通过这些项目的对比,你就能发现koltin取代Java的理由了,kotlin拥有更好的扩展性,更高的性能,更简洁的代码,更好的Jetpack组件支持,如果你还对kotlin不熟悉,那么可以查阅我的kotlin专题博客,在此也要感谢动脑学院Jason老师的辛勤付出,动脑学院在B站上也有投稿koltin基础的视频,通过视频可以快速学习和上手kotlin

今天来综合使用各种组件,搭建最新MVVM项目框架,利用Paging3实现列表功能,Paging3和Paging2一样,支持数据库缓存

 

一、依赖

主项目gradle中导入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编译会出现问题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:触发Hilt的代码生成
  • @AndroidEntryPoint:创建一个依赖容器,该容器遵循Android类的生命周期
  • @Module:告诉Hilt如何提供不同类型的实例
  • @InstallIn:用来告诉Hilt这个模块会被安装到哪个组件上
  • @Provides:告诉Hilt如何获取具体实例
  • @Singleton:单例
  • @ViewModelInject:通过构造函数,给ViewModel注入实例

1.Application注入HiltAndroidApp


@HiltAndroidApp
class APP : Application()  

别忘了在Manifest中配置

2.Activity中开始查找注入对象

使用AndroidEntryPoint注解来表示,Hilt开始查找注入对象


@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注入网络模块

我们准备使用Retrofit封装一个网络模块,需要对该模块使用Module注解InstallIn注解绑定到对应Android类的生命周期,显然整个APP运行过程中,我们都要使用网络模块,所以选择绑定Application


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

提供一个方法给Hilt获取Okhttp对象,此方法为单例,所以使用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()
    }
}  

再提供一个获取Retrofit的方法:


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

完整的网络模块代码:


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()
    }
}  

三、接口与实体类

1.根据接口和接口返回的json数据,分别创建API和实体类

api地址:ikds.do?since=0&pagesize=5 服务器数据:


[
    {
        "id":1,
        "title":"扎克·施奈德版正义联盟",
        "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":"租来的朋友",
        "cover":"https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2616903233.webp",
        "rate":"6.1"
    }
]  

实体类:


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.在网络模块RetrofitModule中新增获取MovieService的方法


{
    @Singleton
    @Provides
    fun provideMovieService(retrofit: Retrofit): MovieService {
        return retrofit.create(MovieService::class.java)
    }
}  

四、Hilt注入数据库模块

1.Room相关基类

使用Room数据库,首先创建Entity,这边加了一个页码的字段:


@Entity
data class MovieEntity(
    @PrimaryKey
    val id: Int,
    val title: String,
    val cover: String,
    val rate: String,
    val page: Int//页码
)  

创建Dao,Room支持返回PagingSource对象,可以直接和我们的Paging结合使用了:

 


@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注入数据库模块

数据库模块同样需要伴随应用的生命周期,所以还是和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配置

我们有了网络模块,数据库模块,接下来就要实现配置Pager,PagingSource我们已经实现了从数据库获取,现在需要的实现的是:网络数据使用RemoteMediator获取

1.网络数据获取:RemoteMediator

结合最初的架构图,RemoteMediator是用于获取网络数据,并将数据存入数据库,我们就可以从数据库获取PagingSource,传递给后续的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函数先放一边,先来实现架构中其他模块

2.对ViewModel暴露获取数据接口:Repository

定义一个Repository接口获取Flow<PagingData<T>>数据,T应该为MovieItemModel,因为对外(ViewModel)而言,使用的都是MovieItemModel网络对象,对内使用的才是MovieEntity数据库对象


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

实现类,使用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对象为Paging<MovieEntity>类型的,因为我们MovieDao返回的是一个PagingSource<Int, MovieEntity>对象,所以需要把MovieEntity转换为MovieItemModel

3.Data Mapper

Data Mapper广泛应用于MyBatis,Data Mapper将数据源的Model(MovieEntity)转换为页面显示Model(MovieItemModel),两者分开的原因就是为了Model层和View层进一步解耦

定义统一转换接口:


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

针对MovieEntity和MovieItemModel实现接口


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对Repository转换

有了Mapper后,就可以将2.中我们的MovieEntity转换为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的生命周期并不是伴随应用的,而是伴随Activity,所以安装到ActivityComponent 同样方法也不是单例的,而是根据Activity,使用ActivityScoped注解


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

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

}  

六、ViewModel

Model层的架构搭建完毕后,我们需要ViewModel层与Model层作数据交互

Hilt注入ViewModel构造函数

ViewModel中需要Repository对象作为属性,而Hilt支持使用ViewModelInject注解给ViewModel构造函数注入


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

七、Adapter与Coil

ViewModel完成后,接下来需要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="泰坦尼克号" />

        <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="评分: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自定义一个image属性 这边选用Coil作为图片加载框架,Coil相较于其他框架拥有更好的性能、更小的体积、易用性、结合了协程、androidx等最新技术、还拥有缓存、动态采样、加载暂停/终止等功能


@BindingAdapter("image")
fun setImage(imageView: ImageView, imageUrl: String) {
    imageView.load(imageUrl) {
        placeholder(R.drawable.ic_launcher_foreground)//占位图
        crossfade(true)//淡入淡出
    }
}  

3.Adapter实现

使用ViewDataBinding作为属性,定义一个基类ViewHolder


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

Adapter继承PagingDataAdapter,并传入一个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添加扩展函数

为了后续Paging的使用,为RecyclerView添加设置Adapter和liveData的扩展函数


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
        )
    }
}  

八、实现RemoteMediator

之前未实现load函数的代码:


@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函数需要一个MediatorResult类型的返回值,MediatorResult有三种返回参数:

  • MediatorResult.Error(e):出现错误
  • MediatorResult.Success(endOfPaginationReached = true):请求成功且有数据(还有下一页)
  • MediatorResult.Success(endOfPaginationReached = false):请求成功但没有数据(到底了)

返回MediatorResult.Success,pager就会从数据库中拿数据,load函数初步实现:


{
        try {
            //1.判断loadType

            //2.请求网络分页数据

            //3.存入数据库

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

2.LoadType

LoadType为枚举类,有三个对象:

  • Refresh:首次加载数据和调用PagingDataAdapter.refresh()时触发
  • Append:加载更多数据时触发
  • Prepend:在列表头部添加数据时触发,Refresh触发时也会触发

第一步就需要判断LoadType的状态,如果是Refresh,那么数据库中没有数据,就要从网络获取数据,Refresh状态下load函数执行完毕后会自动再次调用load函数,此时的LoadType为Append,此时数据库中有数据了,直接返回Success通知Pager可以从数据库取数据了


{
        try {
            //1.判断loadType
            val pageKey = when (loadType) {
                //首次加载
                LoadType.REFRESH -> null
                //REFRESH之后还会调用load(REFRESH时数据库中没有数据),来加载开头的数据,直接返回成功就可以了
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = false)
                //加载更多
                LoadType.APPEND -> {

                }
            }
            
            //2.请求网络分页数据
            val page = pageKey ?: 0
            
            //3.存入数据库

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

3.PagingState

对于下一页的数据,则要使用PagingState获取了,PagingState分为两部分组成:

  • pages:上一页的数据,主要用来获取最后一个item,作为下一页的开始位置
  • config:配置Pager时的PagingConfig,可以获取到pageSize等一系列初始化配置的值

如果上一页最后一个item为空,那么表示列表加载到底了,否则获取到需要加载的当前page


{
                //加载更多
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(
                        endOfPaginationReached = true
                    )
                    lastItem.page//返回当前页
                }
}  

4.网络获取数据和存入数据库

接下来就是从网络获取数据了:


    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, MovieEntity>
    ): MediatorResult {
        try {
            //1.判断loadType
            val pageKey = when (loadType) {
                //首次加载
                LoadType.REFRESH -> null
                //REFRESH之后还会调用load(REFRESH时数据库中没有数据),来加载开头的数据,直接返回成功就可以了
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = false)
                //加载更多
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(
                        endOfPaginationReached = true
                    )
                    lastItem.page//返回当前页
                }
            }

            //2.请求网络分页数据
            val page = pageKey ?: 0
            val result = api.getMovieList(
                page * state.config.pageSize,
                state.config.pageSize
            )

            //3.存入数据库

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

服务器对象转换为本地数据库对象后,存入数据库,完整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.判断loadType
            val pageKey = when (loadType) {
                //首次加载
                LoadType.REFRESH -> null
                //REFRESH之后还会调用load(REFRESH时数据库中没有数据),来加载开头的数据,直接返回成功就可以了
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = false)
                //加载更多
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(
                        endOfPaginationReached = true
                    )
                    lastItem.page//返回当前页
                }
            }

            //2.请求网络分页数据
            val page = pageKey ?: 0
            val result = api.getMovieList(
                page * state.config.pageSize,
                state.config.pageSize
            )

            //服务器对象转换为本地数据库对象
            val entity = result.map {
                MovieEntity(
                    id = it.id,
                    title = it.title,
                    cover = it.cover,
                    rate = it.rate,
                    page = page + 1
                )
            }
            //3.存入数据库
            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)
        }
    }

}  

运行后的效果:

 

联动.gif

九、刷新

1.上拉刷新、重试按钮、错误信息

上拉刷新、重试按钮、错误信息布局文件如下:


<?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="连接超时"/>

</LinearLayout>  

之前我们使用Paging的LoadStateAdapter,直接设置到PagingDataAdapter上就可以了,刷新对应的ViewHolder如下:


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

    fun bindData(data: LoadState){
        binding.apply {
            // 正在加载,显示进度条
            progress.isVisible = data is LoadState.Loading
            // 加载失败,显示并点击重试按钮
            retryButton.isVisible = data is LoadState.Error
            retryButton.setOnClickListener { retryCallback() }
            // 加载失败显示错误原因
            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设置ConcatAdapter,一定要设置成withLoadStateFooter函数返回的Adapter,否则不会有效果!!


        val adapter = MoviePagingAdapter()

        binding.recyclerview.adapter = adapter
            .run { withLoadStateFooter(FooterAdapter(this)) }  

2.下拉刷新

下拉刷新和之前也是相同的,布局中嵌套一个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中对PagingDataAdapter的loadState进行监听:


        lifecycleScope.launchWhenCreated {
            //监听adapter状态
            adapter.loadStateFlow.collect {
                //根据刷新状态来通知swiprefreshLayout是否刷新完毕
                binding.refreshLayout.isRefreshing = it.refresh is LoadState.Loading
            }
        }  

十、App Starup实现无网络数据组件初始化

RemoteMediator中可以在无网络时从数据库获取数据,所以load函数中我们还需要对网络状态进行判断,无网络时,直接返回Success

1.获取网络状态的扩展函数

定义一个扩展函数用来获取网络状态:


@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中不要忘了加权限

2.新建帮助类,初始化Context


object AppHelper {
    lateinit var mContext: Context

    fun init(context: Context) {
        this.mContext = context
    }
}  

3.RemoteMediator中判断网络状态并返回


            //无网络从本地数据库获取数据
            if (!AppHelper.mContext.isConnectedNetwork()) {
                return MediatorResult.Success(endOfPaginationReached = false)
            }  

此时AppHelper的init函数还没有调用

4.App Starup

 

image.png

App Starup是JetPack的新成员,提供了在App启动时初始化组件简单、高效的方法,还可以指定初始化顺序,我们新建一个类继承于Initializer


class AppInitializer : Initializer<Unit> {

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

    //按顺序执行初始化
    override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf()
}  

最后还需要在Manifest中注册:


        <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>  

最终效果:

 

项目地址:https://gitee.com/aruba/mvvmapplication.git

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

kotlin--综合运用Hilt、Paging3、Flow、Room、Retrofit、Coil等实现MVVM架构 的相关文章

  • mybatis foreach 批量删除 传两个参数

    需求 foreach中要传两个参数 xff0c 一个是id xff0c 一个是list 怎么传呢 xff1f 单list的情况 Mapper java span class token comment 批量删除 64 param teamL
  • ubuntu22.04 dlopen(): error loading libfuse.so.2

    报错如下 navicat16 premium cs AppImage dlopen error loading libfuse so 2 AppImages require FUSE to run You might still be ab
  • Android DataBinding在Activity、Fragment中的使用及数据共享

    本篇记录Activity Fragment使用DataBinding的不同方式 xff0c 以及Activity下的不同Fragment间的数据共享 开启DataBinding 首先我们要在app gradle中开启DataBinding
  • IDEA mybatis Mapper.xml报红的解决办法

    现象 在IDEA中已经配置好Database了 xff0c 但是打开mybatis的Mapper xml中的字段还是报红 如下 xff1a 随便不影响程序运行 xff0c 但是非常的不舒服 智能提示也不好用 解决办法 File gt Set
  • Windows 2008 R2 Server远程无法复制的解决办法

    结束进程rdpclip exe运行中重新运行rdpclip exe 两步完美解决 参考 https bbs huaweicloud com blogs 307039
  • WinScp密钥登录

    使用密码登录非常的方便 xff0c 但是有的客户的云服务器上是限定只能通过密钥登录 我一般使用命令行的scp命令就可以正常上传 xff0c 但是对于我一些同事来说 xff0c 就很不方便 生成密钥 这个不难 xff0c 可以参考我之前的文章
  • FileZilla密钥登录

    使用密码登录非常的方便 xff0c 但是有的客户的云服务器上是限定只能通过密钥登录 我一般使用命令行的scp命令就可以正常上传 xff0c 但是对于我一些同事来说 xff0c 就很不方便 生成密钥 这个不难 xff0c 可以参考我之前的文章
  • node js 设置淘宝源

    淘宝镜像源最新地址 span class token function npm span config span class token builtin class name set span registry https registry
  • 手推DNN,CNN池化层,卷积层反向传播

    反向传播算法是神经网络中用来学习的算法 xff0c 从网络的输出一直往输出方向计算梯度来更新网络参数 xff0c 达到学习的目的 xff0c 而因为其传播方向与网络的推理方向相反 xff0c 因此成为反向传播 神经网络有很多种 xff0c
  • 软件架构概念和面向服务的架构

    摘要 软件架构作为软件开发过程的一个重要组成部分 xff0c 有着各种各样的方法和路线图 xff0c 它们都有一些共同的原则 基于架构的方法作为控制系统构建和演化复杂性的一种手段得到了推广 引言 在计算机历史中 xff0c 软件变得越来越复
  • 初识强化学习,什么是强化学习?

    相信很多人都听过 机器学习 和 深度学习 但是听过 强化学习 的人可能没有那么多 那么 什么是强化学习呢 强化学习是机器学习的一个子领域 它可以随着时间的推移自动学习到最优的策略 在我们不断变化的纷繁复杂的世界里 从更广的角度来看 即使是单
  • 强化学习形式与关系

    在强化学习中有这么几个术语 智能体 Agent 环境 Environment 动作 Action 奖励 Reward 状态 State 有些地方称作观察 Observation 奖励 Reward 在强化学习中 奖励是一个标量 它是从环境中
  • 多层网络和反向传播笔记

    在我之前的博客中讲到了感知器 xff08 感知器 xff09 xff0c 它是用于线性可分模式分类的最简单的神经网络模型 xff0c 单个感知器只能表示线性的决策面 xff0c 而反向传播算法所学习的多层网络能够表示种类繁多的非线性曲面 对
  • 如何准备校招?

    秋招已经落尽尾声 xff0c 今天小牛想把自己的学习经验分享给大家 xff0c 避免大家多走弯路 1 首先需要确定自己想从事哪方面的工作 比如服务端开发 xff08 Java开发工程师 xff0c C 43 43 开发工程师 xff09 x
  • 在Kaggle手写数字数据集上使用Spark MLlib的朴素贝叶斯模型进行手写数字识别

    昨天我在Kaggle上下载了一份用于手写数字识别的数据集 xff0c 想通过最近学习到的一些方法来训练一个模型进行手写数字识别 这些数据集是从28 28像素大小的手写数字灰度图像中得来 xff0c 其中训练数据第一个元素是具体的手写数字 x
  • 在Kaggle手写数字数据集上使用Spark MLlib的RandomForest进行手写数字识别

    昨天我使用Spark MLlib的朴素贝叶斯进行手写数字识别 xff0c 准确率在0 83左右 xff0c 今天使用了RandomForest来训练模型 xff0c 并进行了参数调优 首先来说说RandomForest 训练分类器时使用到的
  • PyTorch模型保存与加载

    torch save xff1a 保存序列化的对象到磁盘 xff0c 使用了Python的pickle进行序列化 xff0c 模型 张量 所有对象的字典 torch load xff1a 使用了pickle的unpacking将pickle
  • Ubuntu18.04 上 安装微信(Deepin-Wechat)

    文章目录 一 安装Deepin Wine环境二 安装Deepin 版微信 微信什么时候支持在linux下的安装包啊 xff0c 我的天哪 xff0c 感觉受到了针对 xff0c 各位看官且看下图 xff1a 这里先作声明 xff1a 本文的
  • ROS机器人操作系统——ROS介绍

    AI is the new electricity 1 ROS发展史 本世纪开始 关于人工智能的研究进入了大发展阶段 包括全方位的具体的AI 例如斯坦福大学人工智能实验室STAIR Stanford Artificial Intellige
  • 如何快速学习一门计算机语言

    一 4步掌握一门计算机语言 1 学习语言的语法 xff0c 关键字 xff0c 以及基本的库 xff08 基础阶段 xff09 2 学习语言的第三方库和各个组件 xff08 OS xff0c 数据库 xff0c 网络 xff09 之间的连用

随机推荐

  • CentOs6.8离线安装svn,并设置自动更新

    CentOs6 8离线安装svn xff0c 并设置自动更新 离线安装所需依赖离线安装 GCC xff0c 如果系统已经有 GCC 了 xff0c 跳过这一步需要的 rpm 包安装顺序 离线安装 openssl需要的代码编译设置环境变量 离
  • Linux ping不通,连不上网的解决办法

    Linux ping不通 xff0c 连不上网的解决办法 可能原因是DNS没有配置好 方法一 xff1a 修改vi etc resolv conf 增加如下内容 xff1a nameserver 114 114 114 114 电信的DNS
  • Android——多进程

    之前我们了解了 Java 多线程浅析 Android Handler详解 Android HandlerThread浅析 Java ThreadPool线程池 让我们继续看看Android多进程 xff1a 1 概述 默认情况下 xff0c
  • React、Ant Desgin自定义加载动画,lottie-web 将json解析成动画

    在项目中 xff0c 遇到需要在网页首屏 xff0c 展示动画的需求 xff0c 你会想到怎么做 xff1f 思路一 xff1a 设计师导出gif图片 xff0c 用img进行展示 缺点 xff1a 图片失真 xff0c 影响效果 思路二
  • mac下安装多版本PHP及切换

    mac下安装多版本PHP及切换 工作环境一直是PHP5 6 xff0c 后来发布了PHP7 xff0c 性能提升不少 xff0c 如今打算试试PHP7 xff0c 所以就有了两个php版本的需求 本文的原理就是用一个php管理工具 xff0
  • 短视频拍摄脚本怎么写

    优质的短视频每一个镜头都经过精心设计 xff0c 镜头的设计就是利用镜头脚本 xff0c 提前设想好一切想要的镜头效果和画面 xff0c 最终作品才能一气呵成的呈现出来 xff0c 接下来就来分析一下短视频拍摄脚本怎么写 xff0c 短视频
  • 串口开发之环形缓冲区

    01 简介 串口的基本应用 xff0c 使用串口中断接收数据 xff0c 串口中断发送回包 xff08 一般可以使用非中断形式发送回包 xff0c 在数据接收不频繁的应用中 串口接收中断保证串口数据及时响应 xff0c 使用非中断方式发送回
  • fastboot 命令

    1 fastboot概念 fastboot fastboot是PC与bootloader的USB通信的命令行工具 xff0c 通过向bootloader传送刷机文件 xff08 img xff09 实现Android系统分区重烧 fastb
  • Android Studio 开启视图绑定 viewBinding

    Google 在 Android Studio 3 6 Canary 11 及更高版本中提供了一个 viewBinding 的开关 xff0c 可以开启视图绑定功能 xff0c 以此来替代 findViewById viewBinding功
  • ViewPager 装载fragment 页面显示空白

    ViewPager 装载fragment 页面显示空白 xff0c 这个时候有两种情况 xff1a 在分页面较多的情况下 使用了 FragmentPagerAdapter xff0c 可能会导致第二次加载页面显示空白或是多次滑动页面后页面空
  • The following packages have unmet dependencies: openssh-server : Depends: openssh-client (= 1:6.6p1

    在虚拟机中安装openssh server的时候报了这个错误 xff0c 不知道这台虚拟机抽了什么风 xff0c 别的虚拟机都能顺利安装 xff0c xff0c xff0c 提示说是openssh server 依赖于 openssh cl
  • Docker Desktop stopped 问题解决

    推广博客 xff1a Docker Desktop stopped 问题解决
  • windows连接远程桌面必须要有用户名和密码

    被远程连接的电脑如果有用户名但没有密码 xff0c 连接时需要输入密码时空着会导致无法连接 想想也是 xff0c 如果没有密码 xff0c 只要有人连入电脑所在局域网 xff0c 就可以通过ip地址和用户名连入电脑 xff0c 非常不安全
  • Android中APK签名工具之jarsigner和apksigner详解

    一 工具介绍 jarsigner是JDK提供的针对jar包签名的通用工具 位于JDK bin jarsigner exe apksigner是Google官方提供的针对Android apk签名及验证的专用工具 位于Android SDK
  • Android NumberPicker的基本用法及常见问题汇总

    前言 在项目中需要一个选择人数的控件 xff0c 于是想到了NumberPicker xff0c 这个控件相对不是那么热门 xff0c 我也是第一次用 xff0c 所以遇到了一些问题 xff0c 这里做个小结 正文 首先来看一下最终的效果
  • angular将html代码输出为内容

    在前端与后台的撕逼中 xff0c 很大一部分是因为数据的问题 使用angular会遇到这样的问题 xff0c 后台返回的数据不是自己想要的纯字符串 xff0c 而是带有html标签及属性的 xff0c 那么我们将它输出来后 xff0c 在页
  • Jetpack新成员,App Startup一篇就懂

    Android 11系统已经来了 xff0c 随之而来的是 xff0c Jetpack家族也引入了许多新的成员 其实以后Android的更新都会逐渐采用这种模式 xff0c 即特定系统相关的API会越来越少 xff0c 更多的编程API是以
  • appWidget

    构建应用微件 应用微件是可以嵌入其他应用 xff08 如主屏幕 xff09 并接收定期更新的微型应用视图 这些视图称为界面中的微件 xff0c 您可以使用应用微件提供程序发布微件 能够容纳其他应用微件的应用组件称为应用微件托管应用 下面的屏
  • Jetpack新成员,Paging3从吐槽到真香

    各位小伙伴们大家早上好 随着Android 11的正式发布 xff0c Jetpack家族也引入了许多新的成员 我之前有承诺过 xff0c 对于新引入的App Startup Hilt Paging 3 xff0c 我会分别写一篇文章进行介
  • kotlin--综合运用Hilt、Paging3、Flow、Room、Retrofit、Coil等实现MVVM架构

    前面我们使用Java来运用JetPack中的一系列组件 xff0c 又使用kotlin运用这些组件实现了一系列功能 xff1a kotlin Flow文件下载kotlin Flow结合Room运用kotlin Flow结合retrofit运