美文网首页Android Kotlin
再探Kotlin 跨平台——迁移Paging分页库至KMM

再探Kotlin 跨平台——迁移Paging分页库至KMM

作者: BlueSocks | 来源:发表于2023-01-16 14:24 被阅读0次

    前言

    KMM的发展除了靠官方社区的支持外,一些大企业的开源落地也尤为重要。从这些开源中我们需要借鉴他的设计思想和实现方式。从而在落地遇到问题时,寻得更多的解决办法。

    上周,Square正式将Paging分页库迁移到了Kotlin Multiplatform平台,使用在旗下的支付软件Cash App中。

    image

    迁移过程

    初衷

    据Cash App称,他们想在跨平台中使用分页逻辑,但是AndroidX Paging只支持Android平台。所以他们参照AndroidX下Paging库的设计,实现了一套Multiplatform Paging。

    模型

    image.png

    与AndroidX下的Paging设计一样,paging-common模块提供存储层、视图模型层;paging-runtim模块提供UI层。

    最主要的是,paging-common中的API与AndroidX 下的API完全相同,仅仅是将包从androidx.paging迁移到了app.cash.paging中,所以这部分的使用我们直接按照AndroidX中的Paging使用即可。如果之前项目已经使用了AndroiX的Paging库,则可以在Android平台上无缝迁移。

    如果你之前从未使用过Paging库,可以参考许久之前我写的两篇相关文章:

    在View中使用Paging3分页库

    在Compose中使用分页库

    接下来我们就以multiplatform-paging-samples为例,来看如何实现在Multiplatform使用Paging库。

    项目分析

    项目介绍

    multiplatform-paging-samples项目(Demo)的功能是使用github的接口:api.github.com/search/repositories 查询项目,输出项目路径和start数量。

    也就是github主页上的搜索功能。App运行截图如下所示。

    image

    这里我们搜索关键词为“MVI”,左侧输出为作者/项目名 右侧为start数量,且实现了分页功能。接着我们来看这个项目结构是怎么样的。

    项目架构

    image.png

    从项目架构中可以看出在共享模块中,只有iosMain并没有AndroidMain,这是因为我们前面所讲到的针对Android平台是可以无缝迁移的。接着我们再来看shared模块中的通用逻辑。

    commonMain通用逻辑

    models.kt文件中定义了若干数据结构,部分代码如下所示。

    sealed interface ViewModel {
    
      object Empty : ViewModel
    
      data class SearchResults(
        val searchTerm: String,
        val repositories: Flow<PagingData<Repository>>,
      ) : ViewModel
    }
    
    @Serializable
    data class Repositories(
      @SerialName("total_count") val totalCount: Int,
      val items: List<Repository>,
    )
    
    @Serializable
    data class Repository(
      @SerialName("full_name") val fullName: String,
      @SerialName("stargazers_count") val stargazersCount: Int,
    )
    
    

    RepoSearchPresenter类中主要做了三件事:

    • 定义HttpClient对象

    • 定义Pager与PagerSource

    • 定义查询数据的方法

    定义HttpClient对象

    这里的网络请求框架使用的是Ktor,代码如下所示:

    private val httpClient = HttpClient {
      install(ContentNegotiation) {
        val json = Json {
          ignoreUnknownKeys = true
        }
        json(json)
      }
    }
    
    

    定义Pager与PagerSource

    pager的声明如下所示:

    private val pager: Pager<Int, Repository> = run {
      val pagingConfig = PagingConfig(pageSize = 20, initialLoadSize = 20)
      check(pagingConfig.pageSize == pagingConfig.initialLoadSize) {
        "As GitHub uses offset based pagination, an elegant PagingSource implementation requires each page to be of equal size."
      }
      Pager(pagingConfig) {
          RepositoryPagingSource(httpClient, latestSearchTerm)
      }
    }
    
    

    这里指定了pageSize的大小为20,并调用PagerSource的方法,RepositoryPagingSource声明如下所示:

    private class RepositoryPagingSource(
      private val httpClient: HttpClient,
      private val searchTerm: String,
    ) : PagingSource<Int, Repository>() {
    
      override suspend fun load(params: PagingSourceLoadParams<Int>): PagingSourceLoadResult<Int, Repository> {
        val page = params.key ?: FIRST_PAGE_INDEX
        println("veyndan___ $page")
        val httpResponse = httpClient.get("https://api.github.com/search/repositories") {
          url {
            parameters.append("page", page.toString())
            parameters.append("per_page", params.loadSize.toString())
            parameters.append("sort", "stars")
            parameters.append("q", searchTerm)
          }
          headers {
            append(HttpHeaders.Accept, "application/vnd.github.v3+json")
          }
        }
        return when {
          httpResponse.status.isSuccess() -> {
            val repositories = httpResponse.body<Repositories>()
            println("veyndan___ ${repositories.items}")
            PagingSourceLoadResultPage(
              data = repositories.items,
              prevKey = (page - 1).takeIf { it >= FIRST_PAGE_INDEX },
              nextKey = if (repositories.items.isNotEmpty()) page + 1 else null,
            ) as PagingSourceLoadResult<Int, Repository>
          }
          httpResponse.status == HttpStatusCode.Forbidden -> {
            PagingSourceLoadResultError<Int, Repository>(
              Exception("Whoops! You just exceeded the GitHub API rate limit."),
            ) as PagingSourceLoadResult<Int, Repository>
          }
          else -> {
            PagingSourceLoadResultError<Int, Repository>(
              Exception("Received a ${httpResponse.status}."),
            ) as PagingSourceLoadResult<Int, Repository>
          }
        }
      }
    
      override fun getRefreshKey(state: PagingState<Int, Repository>): Int? = null
    
    

    这部分代码没什么好解释的,和AndroidX的Paging使用是一样的。

    定义查询数据的方法

    这里还定一个一个查询数据的方法,使用flow分发分发给UI层,代码如下所示:

    suspend fun produceViewModels(
        events: Flow<Event>,
      ): Flow<ViewModel> {
        return coroutineScope {
          channelFlow {
            events
              .collectLatest { event ->
                when (event) {
                  is Event.SearchTerm -> {
                    latestSearchTerm = event.searchTerm
                    if (event.searchTerm.isEmpty()) {
                      send(ViewModel.Empty)
                    } else {
                      send(ViewModel.SearchResults(latestSearchTerm, pager.flow))
                    }
                  }
                }
              }
          }
        }
      }
    }
    
    

    这里的Event是定义在models.kt中的密封接口。代码如下所示:

    sealed interface Event {
    
      data class SearchTerm(
        val searchTerm: String,
      ) : Event
    }
    
    

    iosMain的逻辑

    在iosMain中仅定义了两个未使用的方法,用于将类型导出到Object-C或Swift,代码如下所示。

    @Suppress("unused", "UNUSED_PARAMETER") // Used to export types to Objective-C / Swift.
    fun exposedTypes(
      pagingCollectionViewController: PagingCollectionViewController<*>,
      mutableSharedFlow: MutableSharedFlow<*>,
    ) {
      throw AssertionError()
    }
    
    @Suppress("unused") // Used to export types to Objective-C / Swift.
    fun <T> mutableSharedFlow(extraBufferCapacity: Int) = MutableSharedFlow<T>(extraBufferCapacity = extraBufferCapacity)
    
    

    Android UI层实现

    Android UI层的实现比较简单,定义了一个event用于事件分发

    val events = MutableSharedFlow<Event>(extraBufferCapacity = Int.MAX_VALUE)
    lifecycleScope.launch {
      viewModels.emitAll(presenter.produceViewModels(events))
    }
    
    

    当输入框中的内容改变时,发送事件,收到结果显示数据即可,代码如下所示:

    @Composable
    private fun SearchResults(repositories: LazyPagingItems<Repository>) {
      LazyColumn(
        Modifier.fillMaxWidth(),
        contentPadding = PaddingValues(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
      ) {
        when (val loadState = repositories.loadState.refresh) {
          LoadState.Loading -> {
            item {
              CircularProgressIndicator()
            }
          }
          is LoadState.NotLoading -> {
            items(repositories) { repository ->
              Row(Modifier.fillMaxWidth()) {
                Text(
                  repository!!.fullName,
                  Modifier.weight(1f),
                )
                Text(repository.stargazersCount.toString())
              }
            }
          }
          is LoadState.Error -> {
            item {
              Text(loadState.error.message!!)
            }
          }
        }
      }
    }
    
    

    iOS平台的实现

    AppDelegate.swift文件是程序启动入口文件,RepositoryCell类继承自UICollectionViewCell,并补充了API中返回的字段信息,UICollectionViewCell是iOS中的集合视图,代码如下所示:

    class RepositoryCell: UICollectionViewCell {
      @IBOutlet weak var fullName: UILabel!
      @IBOutlet weak var stargazersCount: UILabel!
    }
    
    

    iOS触发查询代码如下所示:

    extension RepositoriesViewController: UITextFieldDelegate {
      func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        let activityIndicator = UIActivityIndicatorView(style: .gray)
        textField.addSubview(activityIndicator)
        activityIndicator.frame = textField.bounds
        activityIndicator.startAnimating()
    
        self.collectionView?.reloadData()
    
        activityIndicator.removeFromSuperview()
    
        events.emit(value: EventSearchTerm(searchTerm: textField.text!), completionHandler: {error in
          print("error", error ?? "null")
        })
    
        presenter.produceViewModels(events: events, completionHandler: {viewModels,_ in
          viewModels?.collect(collector: ViewModelCollector(pagingCollectionViewController: self.delegate), completionHandler: {_ in print("completed")})
        })
    
        textField.resignFirstResponder()
        return true
      }
    }
    
    

    对iOS不太了解,就不详细讲解了。(偷偷学习一波~,让iOS无路可走)

    写在最后

    KMM的发展出除了靠官方社区的支持之外,一些有名项目的落地实践也很重要。目前我们所能做的就是持续关注KMM的动态,探索可尝试落地的组件,为己所用。

    本文转自 https://juejin.cn/post/7166936006103400479,如有侵权,请联系删除。

    相关文章

      网友评论

        本文标题:再探Kotlin 跨平台——迁移Paging分页库至KMM

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