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

·  阅读 850

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第4天,点击查看活动详情

前言

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

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

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

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

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

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

程序截图

Screenshot_1.pngScreenshot_2.png
Screenshot_3.pngScreenshot_4.png
Screenshot_5.pngScreenshot_6.png)

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

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

实现过程

整体结构

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

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

整体的页面结构如下:

pager.png

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

实现 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,  // 是否正在登录中(用于控制登录动画的显示)
)
复制代码

各个字段的用途已在注释中标明,需要说明的是,因为 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)
                }
            }
        }
    }
}
复制代码

从上述代码中,可以看到,通过 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() // 更新邮箱输入框内容
    // ......
}
复制代码

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

    fun dispatch(action: LoginViewAction) {
        when (action) {
            // ......
            is LoginViewAction.Login -> login()
            is LoginViewAction.UpdateEmail -> updateEmail(action.email)
            // ......
        }
    }
复制代码

在接受到请求后,进行相应的逻辑处理或者更改状态,例如 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)

        // 对请求返回数据进行处理
        // ......
    }
}
复制代码

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

使用 retrofit2 实现网络请求

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

定义请求接口

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

接口参数如下:

get_user_api.jpg

接口定义如下:

interface UserApi {

    /**
     * 获取用户信息
     * */
    @GET("user/")
    suspend fun getUser(@Query("access_token") accessToken: String): Response<User>
}
复制代码

我们定义了一个名为 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()
    }
}
复制代码

上述代码构造了 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()
    
    // ......
}
复制代码

这里我们需要将其改成 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)
    
    // ......
    
}
复制代码

在上述代码中,我们定义了一个 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() { 
    // ......
}
复制代码

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

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

val loginViewModel: LoginViewModel = LoginViewModel()
复制代码

注入 xxViewModel

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

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

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val oAuthApi: OAuthApi,
    private val userApi: UserApi
) : ViewModel() {
    // ......
}
复制代码

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

@Composable
fun LoginScreen(
    navController: NavHostController,
    viewModel: LoginViewModel = hiltViewModel()
) { 
    // ......
}
复制代码

调用 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
)
复制代码

然后定义 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()
}
复制代码

注意, 因为这是给分页库使用的数据,所以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>() {
    // ......
}
复制代码

其中 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
)
复制代码

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
}
复制代码

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)
    }
}
复制代码

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

有一点值得注意的是,因为分页库不支持使用简单的页码标记分页项,而是只支持使用 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)
}
复制代码

因为我需要在 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) ....
}
复制代码

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

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

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

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

UI 界面

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

class TodoListWidgetReceiver : GlanceAppWidgetReceiver() {
    // ......
    override val glanceAppWidget: GlanceAppWidget = TodoListWidget()
    // ......
}
复制代码

这个 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)
    }
}
复制代码

接下来 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)))
    }
    // ......
}
复制代码

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

其中,refreshActionPar 参数定义如下:

const val ACTION_NAME = "actionName"

val actionKey = ActionParameters.Key<String>(ACTION_NAME)
val refreshActionPar = actionParametersOf(actionKey to TodoListWidgetCallback.UPDATE_ACTION)
复制代码

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"
    }
}
复制代码

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

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)
                }
            }
        }
    }
    
    // ......
}
复制代码

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

总结

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

仓库地址:GiteeTodo

欢迎 star~

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

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

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

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

分类:
Android
标签:
分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改