• 使用Compose实现基于MVI架构、retrofit2、支持 glance 小部件的TODO应用


    前言

    现在声明式 UI 已逐渐成为主流,在客户端上,已有成熟的 Flutter 和 SwiftUi ,而原生安卓上的声明式 UI 却在去年年底才姗姗来迟。

    虽然 compose 姗姗来迟,但是关于它的文章现在已经有很多了,这里就不再赘述,本文主要介绍如何使用 compose 实现一个 TODO 应用。

    当然,既然要学习新的技术,那自然是不能只学习一个,索性就在这一个 APP 里面全部学习了吧。

    因此该 APP 是基于 Gitee ISSUE 作为服务端,使用 MVI 框架,以 retrofit2 作为请求库,使用了依赖注入、数据分页,分页数据缓存数据库,支持 glance 桌面小部件的 TODO APP。

    对了,虽然关于 compose 和 MVI,我最先发的文章是使用 compose 基于 MVI 架构实现康威生命游戏 但是事实上我真正用来入门上手 compose 和学习 MVI 架构的是本文所述的这个项目,因此这个项目可能会有很多不合理的地方,这也是我一直没有写这篇文章的原因。

    好在经过生命游戏的练手,对 compose 的理解又上升了一点,之前一直不知道怎么解决的问题,现在又有了一些新的思路,所以我现在开始着手写这篇文章。

    程序截图

    请添加图片描述
    请添加图片描述
    请添加图片描述
    请添加图片描述
    请添加图片描述
    请添加图片描述

    ps:这些截图都是很早之前的版本的了,现在 UI 改动挺大的,但是我也懒得重新截图了,有需要的去 Github 下载源码编译试试(狗头)

    pps:对了,这个项目其实两个月前就写好了,只是一直没来得及发,哈哈,看我 Github 的提交记录就知道

    实现过程

    整体结构

    在开始之前先捋清楚需要实现什么功能,都有哪些页面。

    由于是基于 Gitee 的 ISSUE 实现的,所以启动时肯定要先登录并且选择一个仓库后才能继续。

    整体的页面结构如下:

    请添加图片描述

    我的想法是启动时无论是否已经登录都应该先进入登录页,然后在登录页进行判断,已登录则跳转至主页,未登录则展示登录页面。

    实现 MVI 架构

    在写这个 APP 时,架构方面参考了 wanandroid-compose 的实现方法。

    MVI 指的是 Model - View - Intent。

    以登录页面为例:

    定义 Model

    在 MVI 中的 Model 指 UI 状态:

    data class LoginViewState(
        val email: String = "", // 电子邮箱输入框内容
        val password: String = "", // 密码或密钥输入框内容
        val emailLabel: String = "邮箱", // 邮箱提示信息
        val passwordLabel: String = "密码", // 密码或密钥提示信息
        val accessLoginTitle: String = "私人令牌登录", // 密钥登录按钮文字
        val isPasswordVisibility: Boolean = false,  // 是否明文显示密码
        val isEmailError: Boolean = false,  // 邮箱是否输入错误
        val isPassWordError: Boolean = false, // 密码或密钥是否输入错误
        val isShowEmailEdit: Boolean = true,  // 是否显示邮箱输入框
        val isShowLoginHelpDialog: Boolean = false,  // 是否显示帮助 Dialog
        val isLogging: Boolean = true,  // 是否正在登录中(用于控制登录动画的显示)
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    各个字段的用途已在注释中标明,需要说明的是,因为 Gitee 访问 OpenApi 需要使用 token,而 token 有三种方式可以获得:

    1. 直接生成
    2. 通过账号密码获取
    3. 通过 OAuth2 授权获取

    所以我在设计这个应用时,把三种登录方式都涵盖到了其中,所以登录页面的 UI 信息变动会比较多,定义的状态相对应的也比较多。

    定义 View

    MVI 中的 View 和其他架构的 view 没有太大区别,在这里我们使用的是 compose 作为Ui,因为代码比较长,所以我截取其中的一部分:

    @Composable
    fun LoginContent() {
        val loginViewModel: LoginViewModel = viewModel()
        val viewState = loginViewModel.viewStates
        val context = LocalContext.current
    
        Column(Modifier.background(MaterialTheme.colors.baseBackground)) {
    
            Column(Modifier.weight(9f)) {
                Row(
                    Modifier
                        .fillMaxWidth()
                        .padding(top = 100.dp),
                    horizontalArrangement = Arrangement.Center) {
    
                    Text(text = "登录", fontSize = 30.sp, fontWeight = FontWeight.Bold, letterSpacing = 10.sp)
                }
    
                if (viewState.isShowEmailEdit) {
                    Row(
                        Modifier
                            .fillMaxWidth()
                            .padding(top = 8.dp),
                        horizontalArrangement = Arrangement.Center) {
                        EmailEditWidget(loginViewModel, viewState)
                    }
                }
    
                Row(
                    Modifier
                        .fillMaxWidth()
                        .padding(top = 8.dp),
                    horizontalArrangement = Arrangement.Center) {
                    PasswordEditWidget(loginViewModel, viewState)
                }
    
                Row(
                    horizontalArrangement = Arrangement.End,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(start = 32.dp, end = 32.dp, top = 4.dp)) {
                    LinkText("注册账号") {
                        loginViewModel.dispatch(LoginViewAction.Register(context = context))
                    }
                }
    
                Row(
                    Modifier
                        .fillMaxWidth()
                        .padding(top = 32.dp), horizontalArrangement = Arrangement.Center) {
                    Button(
                        onClick =
                        {
                            loginViewModel.dispatch(LoginViewAction.Login)
                        },
                        shape = Shapes.large) {
                        Text(text = "登录", fontSize = 20.sp, modifier = Modifier.padding(start = 82.dp, end = 82.dp, top = 4.dp, bottom = 4.dp))
                    }
                }
            }
    
            Column(Modifier.weight(1.5f)) {
                Row(
                    Modifier
                        .fillMaxWidth()
                        .padding(top = 16.dp), verticalAlignment = Alignment.CenterVertically
                ) {
                    Divider(
                        thickness = 2.dp,
                        modifier = Modifier
                            .padding(start = 8.dp)
                            .weight(1f)
                    )
                    Row(horizontalArrangement = Arrangement.Center,
                        verticalAlignment = Alignment.CenterVertically,
                        modifier = Modifier
                            .padding(start = 8.dp)
                            .weight(1f)) {
                        Text(text = "其他登录方式")
                        IconButton(onClick = { loginViewModel.dispatch(LoginViewAction.ShowLoginHelp) }) {
                            Icon(Icons.Outlined.HelpOutline, contentDescription = "疑问", tint = MaterialTheme.colors.primary)
                        }
                    }
                    Divider(
                        thickness = 2.dp,
                        modifier = Modifier
                            .padding(end = 8.dp, start = 8.dp)
                            .weight(1f)
                    )
                }
    
                Row(
                    Modifier
                        .fillMaxWidth()
                        .padding(32.dp, 0.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
                    LinkText("OAuth2授权登录") {
                        loginViewModel.dispatch(LoginViewAction.SwitchToOAuth2)
                    }
                    LinkText(viewState.accessLoginTitle) {
                        loginViewModel.dispatch(LoginViewAction.SwitchToAccessToken)
                    }
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105

    从上述代码中,可以看到,通过 viewModel() 方法拿到了当前页面的 viewModel : loginViewModel, 再通过 loginViewModel 可以拿到当前页面的状态 viewState

    因为 MVI 强调数据的单向流动与状态的统一管理,所以当前页面的所有状态都写在了 viewState 中,而如果想要更改状态,不能直接修改 viewState 的属性,而应该通过 loginViewModel.dispatch(LoginViewAction) 来修改。

    至于这个 dispatch 方法和 viewModel 的定义,我们接下来就会说。

    定义 Intent

    MVI 中的 Intent 非安卓中 Intent 而是泛指所有的用户操作。

    在这里,我们定义一个 Action 用来表示用户操作:

    sealed class LoginViewAction {
        // ......
        object Login : LoginViewAction()  // 登录
        data class UpdateEmail(val email: String) : LoginViewAction() // 更新邮箱输入框内容
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    然后声明一个 dispatch 用来接受用户的操作:

        fun dispatch(action: LoginViewAction) {
            when (action) {
                // ......
                is LoginViewAction.Login -> login()
                is LoginViewAction.UpdateEmail -> updateEmail(action.email)
                // ......
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在接受到请求后,进行相应的逻辑处理或者更改状态,例如 login 方法:

    private fun login() {
        // ......
        // 检查输入内容
        if (viewStates.email.isBlank()) {
            // 更新邮箱提示信息状态
            viewStates = viewStates.copy(isEmailError = true, emailLabel = "请输入邮箱")
            return
        }
        // ......
        
        // 更改为正在登录状态
        viewStates = viewStates.copy(isLogging = true)
    
        // 启动协程
        viewModelScope.launch {
            // 开始发送请求
            val response = oAuthApi.getTokenByPsw(
                viewStates.email,
                viewStates.password,
                ClientInfo.ClientId,
                ClientInfo.ClientSecret)
    
            // 对请求返回数据进行处理
            // ......
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    自此,MVI 的基本架构就构建完成了,剩下的页面根据需求照葫芦画瓢依次写出来就完事了。

    使用 retrofit2 实现网络请求

    作为一个完全在线版的 TODO APP,网路请求是必不可少的,由于 Gitee 的 OpenApi 是基本参照 Github 的 OpenApi 编写的,所以它的所有接口都是 restful 的,对于这种接口,用 retrofit2 在适合不过了。

    定义请求接口

    使用 retrofit2 前我们需要先定义一个接口,用于后续的请求,这里以 UserApi 为例:

    接口参数如下:

    请添加图片描述

    接口定义如下:

    interface UserApi {
    
        /**
         * 获取用户信息
         * */
        @GET("user/")
        suspend fun getUser(@Query("access_token") accessToken: String): Response<User>
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    我们定义了一个名为 UserApi 的接口,用于处理所有与用户信息相关的请求,当然,这里我们只需要一个接口,就是 getUser 用于获取用户信息。

    新版 retrofit2 已经支持了 kotlin 的协程,因此此处我们使用了挂起函数 suspend fun getUser

    对该方法使用 @GET("user/") 标明是 GET 请求,并且请求路径为 user/ ,最后在方法参数中添加 @Query("access_token") 注解,标明需要构造的请求参数。

    构造 RetrofitManger

    定义好 API 接口后我们需要编写一个 RetrofitManger 单例类,用于构建并获取定义好的 API 接口:

    object RetrofitManger {
        
        // ......
        private var userApi: UserApi? = null
    
        private const val CONNECTION_TIME_OUT = 10L
        private const val READ_TIME_OUT = 10L
        
    
        var BaseUrl = "https://gitee.com/api/v5/"
        
        // ......
    
        fun getUserApi(): UserApi {
            if (userApi == null) {
                synchronized(this) {
                    if (userApi == null) {
                        val okHttpClient =
                            buildOkHttpClient()
                        userApi =
                            buildRetrofit(
                                BaseUrl,
                                okHttpClient
                            ).create(UserApi::class.java)
                    }
                }
            }
            return userApi!!
        }
        
        // ......
    
        private fun buildOkHttpClient(): OkHttpClient.Builder {
            val logging = HttpLoggingInterceptor()
            logging.level = HttpLoggingInterceptor.Level.BODY
            return OkHttpClient.Builder()
                .addInterceptor(logging)
                .connectTimeout(CONNECTION_TIME_OUT, TimeUnit.SECONDS)
                .readTimeout(READ_TIME_OUT, TimeUnit.SECONDS)
                .proxy(Proxy.NO_PROXY)
        }
    
        private fun buildRetrofit(baseUrl: String, builder: OkHttpClient.Builder): Retrofit {
            val client = builder.build()
    
            return Retrofit.Builder()
                .baseUrl(baseUrl)
                .addConverterFactory(GsonConverterFactory.create())
                .client(client).build()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    上述代码构造了 Retrofit 对象,并设置好相应参数,最后传入 UserApi 接口,自此我们的 UserApi 已构造完成。

    需要使用时只需要 val userApi = RetrofitManger.getUserApi() 然后 userApi.getUser() 即可完成请求。

    使用依赖注入

    依赖注入是实现控制反转的一种方式,通过控制反转可以将不同类之间的强耦合关系解耦。

    强耦合的缺陷自不必多说,不方便修改,不方便测试。

    我在接手公司的某项目时就遇到了强耦合的坑,因为某个老项目开发时写的比较随意,原代码各种强耦合各种嵌套依赖。

    后来有个需求,让我给这个项目加上单元测试,差点没把我累死,几乎每加一个测试就得把要测试的地方重构一遍以解耦,不然压根没法写测试用例。

    扯远了,我们来说一下在这个项目中如何实现依赖注入,以及哪些地方需要使用依赖注入。

    在依赖注入框架上我们选择的是 Hilt

    需要依赖注入的地方

    在本 项目 中,使用了 retrofit2 作为网络请求库,所以首当其冲的,需要在使用 retrofit2 的地方用依赖注入,避免强耦合。

    其次,由于 MVI 框架的特性,项目中有大量的 xxViewModel ,这同样是需要注入的类。

    最后,在数据分页时我使用了 room 实现数据缓存,这同样是需要注入的地方。

    注入 retrofit2 API

    LoginViewModel 中,我们需要使用两个 retrofit2 API :OAuthApiUserApi 分别用于验证和获取用户信息:

    class LoginViewModel() : ViewModel() {
        // ......
        private val oAuthApi: OAuthApi = RetrofitManger.getOAuthApi()
        private val userApi: UserApi = RetrofitManger.getUserApi()
        
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这里我们需要将其改成 LoginViewModel 的参数,并且使用依赖注入,降低 viewModel 对 Retrofit 的耦合。

    创建Hilt模块

    Hilt 模块可以告知 Hilt 在需要某些实例时使用哪种实现,例如这里我们给 userApi 定义一个 Provides 指定如何实现 userApi

    @Module
    @InstallIn(SingletonComponent::class)
    object NetworkModule {
    
        @Singleton
        @Provides
        fun provideOkHttpClient() = run {
            val logging = HttpLoggingInterceptor()
            logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
            OkHttpClient.Builder()
                .addInterceptor(logging)
                .connectTimeout(Net.CONNECTION_TIME_OUT, TimeUnit.SECONDS)
                .readTimeout(Net.READ_TIME_OUT, TimeUnit.SECONDS)
                .proxy(Proxy.NO_PROXY)
                .build()
        }
    
        @Singleton
        @Provides
        fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl(Net.BASE_URL)
            .client(okHttpClient)
            .build()
        
        // ......
    
        @Singleton
        @Provides
        fun provideUserApiService(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java)
        
        // ......
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    在上述代码中,我们定义了一个 provideUserApiService 并使用 @Provides 注解绑定 UserApi 的实例注入方法。

    provideUserApiService 中通过 retrofit.create(UserApi::class.java) 构建 UserApi ,与上面直接使用 RetrofitManger 构建一样,不同的是,这里的 retrofit 也来自依赖注入。

    而它的定义就在上面 provideRetrofitprovideRetrofit 又依赖了 okHttpClient,哈哈,没错,这就是个套娃,虽然有点绕,但是稍微捋一捋就明白了。

    让 LoginViewModel 使用注入

    定义好 userApi 的注入方法后,下一步就是让 LoginViewModel 使用这个依赖,更改 LoginViewModel 类定义为:

    class LoginViewModel @Inject constructor(
        private val oAuthApi: OAuthApi,
        private val userApi: UserApi
    ) : ViewModel() { 
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在构造方法前加上 @Inject 注解表示该类中的构造参数使用依赖注入。加上这个注解后,Hilt 会自动将 oAuthApiuserApi 按照我们定义的实例化方法构造出来然后注入到这里。

    如此一来,我们在需要用到 LoginViewModel 的地方,直接实例化即可,不需要填入它的参数,它的两个参数会被 Hilt 自动注入:

    val loginViewModel: LoginViewModel = LoginViewModel()
    
    • 1

    注入 xxViewModel

    为了方便使用,我们连 viewModel 都不想在自己实例化,能不能自动注入呢?

    答案当然是可以,我们只需要在类定义时加上 @HiltViewModel 注解即可:

    @HiltViewModel
    class LoginViewModel @Inject constructor(
        private val oAuthApi: OAuthApi,
        private val userApi: UserApi
    ) : ViewModel() {
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这样其他类在使用 viewModel 时就可以直接使用注入的依赖,例如:

    @Composable
    fun LoginScreen(
        navController: NavHostController,
        viewModel: LoginViewModel = hiltViewModel()
    ) { 
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    调用 LoginScreen 时,无需填写 viewModel 参数,这个参数会被 Hilt 自动注入。

    其他类的注入

    其他类和上述两个类情况基本差不多,这里就不过多赘述了。

    数据分页与缓存

    由于 TODO 列表的 item 理论上来说可以是无限多的,如果不适用分页加载那么必定会造成加载时缓慢甚至卡顿。所以我在 TOOD 列表页获取数据时使用到了 paging 库做分页处理,同时为了增加用户体验,我还使用了 room 做缓存。

    定义 ROOM

    正如上文所说,由于使用到了 room 做数据缓存,所以必须创建 room 需要的各种类。

    首先定义数据库的实体类:

    @Entity(tableName = "issues_show_data")
    data class TodoShowData(
        @PrimaryKey
        val id: Int,
        val title: String,
        val number: String,
        val state: IssueState,
        val updateAt: String,
        val createdAt: String,
        val labels: List<Label>,
        val headerTitle: String
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    然后定义 Dao ,对于给分页库使用的 Dao,除了常规的方法外,还必须实现 insertAll() 用于将数据插入数据库; clearAll() 删除所有数据:

    @Dao
    interface IssueDao {
        @Insert(onConflict = OnConflictStrategy.REPLACE)
        suspend fun insertAll(users: List<TodoShowData>)
    
        @Query("SELECT * FROM issues_show_data ORDER BY updateAt ASC")
        fun pagingSourceOrderByAsc(): PagingSource<Int, TodoShowData>
    
        @Query("SELECT * FROM issues_show_data ORDER BY updateAt DESC")
        fun pagingSourceOrderByDesc(): PagingSource<Int, TodoShowData>
    
        @Query("DELETE FROM issues_show_data")
        suspend fun clearAll()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    注意, 因为这是给分页库使用的数据,所以Dao 返回的数据类型是 PagingSource

    定义 RemoteMediator

    RemoteMediator 用来实现当前数据已经用尽或者数据失效时从服务器请求新的数据。

    构造参数

    一般来说,典型的 RemoteMediator 类构造参数应该有三个:

    1. query: 用于定义查询参数
    2. database: 用于缓存的数据库
    3. networkService: 请求实例

    基于我们的需求,我们将 RemoteMediator 定义如下:

    class IssueRemoteMediator(
        private val queryParameter: QueryParameter,
        private val database: IssueDb,
        private val repoApi: RepoApi
    ) : RemoteMediator<Int, TodoShowData>() {
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    其中 queryParameter 是我自定义的一个查询参数数据类:

    data class QueryParameter(
        val repoPath: String = "null/null",
        val state: String? = null,
        val accessToken: String = "",
        val labels: String? = null,
        val direction: String = "desc",
        val createdAt: String? = null
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    database 即 room 数据库操作实例类(RoomDatabase),通过 create 方法创建得到:

    @Database(
        entities = [TodoShowData::class, IssueRemoteKey::class],
        version = 1,
        exportSchema = false
    )
    @TypeConverters(IssueConverters::class)
    abstract class IssueDb : RoomDatabase() {
        companion object {
            fun create(context: Context, useInMemory: Boolean = false): IssueDb {
                val databaseBuilder = if (useInMemory) {
                    Room.inMemoryDatabaseBuilder(context, IssueDb::class.java)
                } else {
                    Room.databaseBuilder(context, IssueDb::class.java, "issue_show_data.db")
                }
                return databaseBuilder
                    .fallbackToDestructiveMigration()
                    .build()
            }
        }
    
        abstract fun issue(): IssueDao
        abstract fun issueRemoteKey(): IssueRemoteKeyDao
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    repoApi 即 retrofit2 API。

    而继承的 RemoteMediator 的两个泛型,分别表示 key 和 value 的类型,这里我的 key 是 Int 类型,value 是上面定义的 TodoShowData 类型。

    load()

    我们需要在 RemoteMediator 中重写 load 方法来定义加载行为,load 方法有两个参数:

    1. LoadType,表示负载的类型: REFRESH、 APPEND 或 PREPEND。
    2. PagingState,其中包含到目前为止加载的页面、最近访问的索引以及 PagingConfig 用于初始化分页流的对象信息。

    具体代码如下:

    override suspend fun load(loadType: LoadType, state: PagingState<Int, TodoShowData>): MediatorResult {
        return try {
            val nextPage = when (loadType) {
                /*
                * 如果当前状态为刷新的话则将加载页面设置为 1,也就是从头开始加载
                * */
                LoadType.REFRESH -> {
                    1
                }
                /*
                * 这个表示向上滑动,这里不需要处理向上滑动,所以直接返回完成
                * */
                LoadType.PREPEND -> {
                    return MediatorResult.Success(endOfPaginationReached = true)
                }
                /*
                * 如果loadType是APPEND,那么我们将在列表中查找最后一项,并查看NewsRemoteKeys数据指定的下一页。
                * 使用函数getRemoteKeyForLastItem()我们可以获得最后一个NewsRemoteKeys ,
                * 这样我们就可以从它的nextKey值中获得适当的下一页编号。
                * 如果没有这样的对象,这意味着我们到达了分页的末尾,
                * 在这种情况下,我们将返回MediatorResult.Success并带有endOfPaginationReached = true 。
                * */
                LoadType.APPEND -> {
                    val remoteKeys = getRemoteKeyForLastItem(state)
                        ?: throw InvalidObjectException("Result is empty")
                    remoteKeys.nextKey ?: return MediatorResult.Success(true)
                }
            }
    
            val repoPath = queryParameter.repoPath
    
            if (repoPath == "null/null") {
                return MediatorResult.Error(IllegalArgumentException("路径为空!"))
            }
            val response = repoApi.getAllIssues(
                repoPath.split("/")[0],
                repoPath.split("/")[1],
                queryParameter.accessToken,
                labels = queryParameter.labels,
                state = queryParameter.state,
                direction = queryParameter.direction,
                createdAt = queryParameter.createdAt,
                page = nextPage,
                perPage = when (loadType) {
                    LoadType.REFRESH -> state.config.initialLoadSize
                    else -> state.config.pageSize
                }
            )
    
            if (!response.isSuccessful) {
                throw HttpException(response)
            }
    
            val issueList = response.body() ?: emptyList()
    
            /*
            * 将从服务器获取到的数据重新解析
            *
            * 此步的目的:
            *   1. 服务器返回数据有上百个字段,而且每个字段都互相嵌套,如果都存进数据库不好理清各个字段之间的关系
            *   2. 本地实际使用到的字段不足十个,其他全是冗余字段,即使保存了也不会使用,不如索性不保存了
            * */
            val resultData = resolveIssue(response) // 该方法就是对返回的数据重新解析封装成 TodoShowData
    
            val totalPage = (response.headers()["total_page"] ?: "-1").toIntOrNull() ?: -1
    
            // 此处是将解析好的数据缓存进数据库
            database.withTransaction {
                if (loadType == LoadType.REFRESH) { // 如果刷新的话则先清除所有本地数据
                    issueKeyDao.clearAll()
                    issueDao.clearAll()
                }
    
                val prevKey = if (nextPage == 1) null else nextPage - 1
                val nextKey = if (nextPage >= totalPage || totalPage == -1) null else nextPage + 1
                val keys = issueList.map {
                    IssueRemoteKey(issueId = it.id, prevKey = prevKey, nextKey = nextKey)
                }
                issueKeyDao.insertAll(keys)
                issueDao.insertAll(resultData)
            }
    
            // 返回加载成功,其中的参数标记是否为最后一页
            MediatorResult.Success(
                endOfPaginationReached = nextPage >= totalPage || totalPage == -1
            )
        } catch (tr: Throwable) {
            Log.w(TAG, "load: load data fail", tr)
            MediatorResult.Error(tr)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91

    上面这段代码是干嘛的我已经在注释中说明。

    有一点值得注意的是,因为分页库不支持使用简单的页码标记分页项,而是只支持使用 key 标记加载位置,然后下次加载时从 key 位置继续加载。

    不巧的是 Gitee 的 OpenApi 不支持使用 key 分页,只支持使用页码分页,所以我们需要额外增加一个数据库,用于绑定 key 和页码的关系,具体实现思路和方法可以看 Android Paging 3 library with page and limit parameters ,我就不过多赘述了。

    使用数据

    到目前为止,使用分页库的前期准备工作已完成,下一步就是在 viewModel 中使用这些数据,并且更新到 UI 中。

    在 viewModel 中添加这样一个状态:

    private val queryFlow = MutableStateFlow(QueryParameter())
    
    @OptIn(ExperimentalCoroutinesApi::class, ExperimentalPagingApi::class)
    private val issueData = queryFlow.flatMapLatest {
        Pager(
            config = PagingConfig(pageSize = 50, initialLoadSize = 50),
            remoteMediator = IssueRemoteMediator(it, dataBase, repoApi)
        ) {
            if (it.direction == Direction.ASC.des) {
                dataBase.issue().pagingSourceOrderByAsc()
            }
            else {
                dataBase.issue().pagingSourceOrderByDesc()
            }
        }
            .flow
            .cachedIn(viewModelScope)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    因为我需要在 QueryParameter 更改时重新请求,所以这里用 Flow 包装了一下,并且在请求参数变化时重新请求数据,一般在使用不用这么麻烦,去掉 Flow 相关的地方就行。

    我在这里为 Pager 方法设置了两个参数:

    1. config : 用于配置 分页的一些参数,比如这里我设置一页的数目和初始化加载时数目都为 50
    2. remoteMediator:即我们上面定义的 IssueRemoteMediator

    上面代码中的 issueData 即最终用于获取数据的 Flow。

    然后,我们需要在 UI 中调用 issueDatacollectAsLazyPagingItems() 获取到 LazyPagingItems

    通过该 LazyPagingItems 就可以拿到加载状态和最终加载出来的数据:

    // ......
    val todoPagingItems = viewState.issueData.collectAsLazyPagingItems() // 获取 LazyPagingItems
    // ......
    if (todoPagingItems.itemCount < 1) { // 获取当前 item 数量
        if (todoPagingItems.loadState.refresh == LoadState.Loading) { // 获取当前加载状态
            LoadDataContent("正在加载中…")
        }
        // ......
        else {
            // ......
            ListEmptyContent("还没有数据哦,点击立即刷新", "TIPS: 点击下方 “+” 可以添加你的第一条数据哦\n也可以尝试更换仓库或筛选条件再试试") {
                todoPagingItems.refresh() // 主动刷新数据
            }
        }
    }
    // ......
    todoPagingItems.itemSnapshotList.forEach { item: TodoShowData? -> // 获取并遍历当前加载的所有数据
        // ...... ShowItem(item) ....
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    自此分页和缓存就全部完成了。

    使用 glance 实现用 compose 写桌面小部件

    既然决定了要完全使用 compose 实现这个APP,自然小部件也得用 compose 来做了,所幸谷歌提供了一个叫做 glance 的库,用于支持 compose 撰写小部件。

    关于怎么写小部件以及一些前期配置,我这里就不再过多赘述,感兴趣的可以自行查看文档,现在我就说一下使用 compose 和直接使用 view 有什么区别。

    UI 界面

    首先,Receiver 需要继承自 GlanceAppWidgetReceiver 而非 AppWidgetProvider ,并且重写 glanceAppWidget 属性:

    class TodoListWidgetReceiver : GlanceAppWidgetReceiver() {
        // ......
        override val glanceAppWidget: GlanceAppWidget = TodoListWidget()
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这个 glanceAppWidget 即小部件显示的 UI,在这里我们定义了一个继承自 GlanceAppWidgetTodoListWidget

    class TodoListWidget : GlanceAppWidget() {
        @Composable
        override fun Content() {
            val prefs = currentState<Preferences>()
            val todoList = prefs[TodoListWidgetReceiver.TodoListKey]
            val loadStatus = prefs[TodoListWidgetReceiver.LoadStateKey]
    
    
            TodoListWidgetContent(todoList, loadStatus)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    接下来 UI 的写法就和普通 compose 别无二致了,所以我也不再多说,只是有几点需要注意一下。

    使用 Glance 的时候,注意导包不要导错,Glance 使用的是 androidx.glance.xxx 包,而非一般 compose 的 androidx.compose.xxx

    还有,在 Glance 中,ModifierGlanceModifier

    交互

    接下来我们看看怎么实现和小部件的交互。

    glance 中小部件和用户交互支持:

    1. 启动 Activity
    2. 执行回调
    3. 启动 Service
    4. 启动广播

    其他都很简单,这里我们说一下如何执行回调

    在我们的小部件中,和用户交互的有个地方是点击刷新按钮后刷新数据。

    刷新按钮 的 composable 是这样写的:

    Row(modifier = GlanceModifier.fillMaxWidth().clickable(actionRunCallback<TodoListWidgetCallback>(refreshActionPar))) {
        // ......
        Row(modifier = GlanceModifier.fillMaxWidth(), horizontalAlignment = Alignment.End) {
            Text(text = "刷新",
                style = TextStyle(color = ColorProvider(MaterialTheme.colors.primary)))
        }
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    可以看到,在 clickable 中我们用 actionRunCallback(refreshActionPar)) 执行了 TodoListWidgetCallback 并且附加了 refreshActionPar 参数。

    其中,refreshActionPar 参数定义如下:

    const val ACTION_NAME = "actionName"
    
    val actionKey = ActionParameters.Key<String>(ACTION_NAME)
    val refreshActionPar = actionParametersOf(actionKey to TodoListWidgetCallback.UPDATE_ACTION)
    
    • 1
    • 2
    • 3
    • 4

    TodoListWidgetCallback 定义如下:

    // 其实可以不用 callback 来中转,
    // 可以直接使用 actionSendBroadcast 发送广播的,
    // 但是这里为了方便以后扩展,所以还是统一都使用 callback 转一下
    class TodoListWidgetCallback : ActionCallback {
        override suspend fun onRun(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
            val actionKey = ActionParameters.Key<String>(ACTION_NAME)
            val actionName = parameters[actionKey]
    
            if (actionName == UPDATE_ACTION) {
                val intent = Intent(context, TodoListWidgetReceiver::class.java).apply {
                    action = UPDATE_ACTION
                }
                context.sendBroadcast(intent)
            }
        }
    
        companion object {
            const val ACTION_NAME = "actionName"
            const val UPDATE_ACTION = "updateAction"
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    从上面代码和注释可知,虽然这里用了回调,但是最终还是通过发送广播来刷新的,理由正如注释所说。

    TodoListWidgetReceiver 中重写 onReceive 并处理刷新逻辑:

    override fun onReceive(context: Context, intent: Intent) {
        super.onReceive(context, intent)
    
        // ......
        
        if (intent.action == TodoListWidgetCallback.UPDATE_ACTION) {
            coroutineScope.launch {
    
                // ..........
                
                val todoListResponse = xxxx  // 重新请求数据
    
                // 所有 widget 都更新成同一个内容
                val glanceIdList = GlanceAppWidgetManager(context).getGlanceIds(TodoListWidget::class.java) // 获取当前小部件列表
                for (glanceId in glanceIdList) {  // 遍历小部件
                    glanceId.let {
                        // 更新小部件参数
                        updateAppWidgetState(context, PreferencesGlanceStateDefinition, it) { pref ->
                            pref.toMutablePreferences().apply {
                                this[TodoListKey] = todoTitleList.toJson()
                                this[LoadStateKey] = loadState
                            }
                        }
                        glanceAppWidget.update(context, it)
                    }
                }
            }
        }
        
        // ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    刷新时,按照正常处理逻辑,应该是仅刷新用户点击了刷新按钮的小部件(因为同一个小部件理论上可以添加无数个),但是这里为了方便理解,直接写成了遍历刷新所有小部件。

    总结

    其实我还用到了很多没有在文中提及到的技术或者库,也还有很多东西没有在文中说到,不过这些都可以在源码中找到:

    仓库地址:GiteeTodo

    欢迎 star~

    这个项目写了我好几个月,但是因为是第一次写 compose 项目,所以还是有很多问题。

    比如,在主页我实现了一个炫酷的滑动列表自动进入沉浸式全屏的动画,但是这个动画经常会鬼畜,我找了好久的原因都没找到,现在想想应该是因为监听列表状态和动画冲突了,应该在动画进行时禁用列表状态监听就好了。

    又比如,使用导航库时,可能会出现错误的出栈,比如登录后选择了仓库,此时按返回键应该触发退出程序,但是现在可能会退回到登录界面。

    无论如何,目前这个项目还有很多不完善的地方,但是我会对他进行持续性的更新,同时也欢迎各位大佬提出意见或建议,如果能提交 PR 修复或添加特性那更是感激不尽!

    本文首发于我的博客:likehide.com

  • 相关阅读:
    taro使用defineConstants定义全局变量eslint报错该变量不存在
    备战数学建模32-相关性分析2
    【微软】【ICLR 2022】TAPEX:通过学习神经 SQL 执行器进行表预训练
    回应:淘宝支持使用微信支付?
    多线程【锁策略与CAS的ABA问题】
    百度/迅雷/夸克,网盘免费加速,已破!
    Redis学习(02)列表、集合、Hash、Zset
    I2C接口及时序
    CCLink转Modbus TCP网关_MODBUS网口设置
    1476_OSP以及HASL等几种PCB表面处理工艺了解
  • 原文地址:https://blog.csdn.net/sinat_17133389/article/details/126178280