美文网首页MVVMKotlinkotlin
kotlin--综合运用Hilt、Paging3、Flow、Ro

kotlin--综合运用Hilt、Paging3、Flow、Ro

作者: aruba | 来源:发表于2021-09-25 21:06 被阅读0次
    前面我们使用Java来运用JetPack中的一系列组件,又使用kotlin运用这些组件实现了一系列功能:
    接着,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

    相关文章

      网友评论

        本文标题:kotlin--综合运用Hilt、Paging3、Flow、Ro

        本文链接:https://www.haomeiwen.com/subject/pboogltx.html