一库搞定多平台分页:Paging-Multiplatform让开发变简单!
分页功能是开发应用程序中不可或缺的一部分。无论是滚动浏览商品列表,还是查看社交媒体动态,分页功能都能有效地提升用户体验。然而,当我们涉及到跨平台开发时,如何实现统一的分页逻辑就变成了一个棘手的问题。别担心,Paging-Multiplatform来了!今天我们就来聊聊这款神奇的库是如何让你的多平台开发变得轻松有趣的。
Paging-Multiplatform是什么?
Paging-Multiplatform是一款库,它为AndroidX Paging增加了额外的Kotlin/Multiplatform目标,并提供了在iOS上使用Paging的UI组件。与AndroidX Paging相似,Paging-Multiplatform的主要模块有:
• paging-common:涵盖了数据仓库层和ViewModel层。
• paging-runtime:涵盖了UI层。
但是,与AndroidX Paging的paging-common目标有限且paging-runtime专门针对Android不同,Paging-Multiplatform为paging-common增加了更多目标,并提供了paging-runtime-uikit,这是一个专门针对iOS的UIKit运行时。因此,分页逻辑可以在更多目标之间共享,提供的UI组件可以在Android和iOS上渲染分页项。
使用指南
paging-common
Paging-Multiplatform中的paging-common API与AndroidX Paging中的paging-common API相同(除了命名空间从androidx.paging
变为app.cash.paging
,以及由于Kotlin编译器的限制导致的一些小差异)。因此,要了解如何使用paging-common,请参考AndroidX Paging的官方文档。
支持的目标包括:JVM、iOS、Linux X64和macOS。这些目标上的app.cash.paging:paging-common
通过类型别名委托给androidx.paging:paging-common
。对于JS、MinGW、Linux Arm64、tvOS和watchOS目标,app.cash.paging:paging-common
则委托给AndroidX Paging的分支。
paging-compose-common
Paging-Multiplatform中的paging-compose-common API与AndroidX Paging中的paging-compose API相同(命名空间从androidx.paging
变为app.cash.paging
)。同样,要了解如何使用paging-compose-common,请参考AndroidX Paging的官方文档。
paging-runtime-uikit for iOS
Paging-Multiplatform为iOS提供了PagingCollectionViewController,可以通过UICollectionView来渲染PagingData。PagingCollectionViewController模仿了UICollectionViewController,提供了单元格数量和通过IndexPath检索项的功能。以下是一个Swift示例:
final class FooViewController: UICollectionViewController {
private let delegate = Paging_runtime_uikitPagingCollectionViewController<Foo>()
private let presenter = …
required init(coder: NSCoder) {
super.init(coder: coder)!
presenter.pagingDatas
.sink { pagingData in
self.delegate.submitData(pagingData: pagingData, completionHandler: …)
}
}
override func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection section: Int
) -> Int {
return Int(delegate.collectionView(collectionView: collectionView, numberOfItemsInSection: Int64(section)))
}
override func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FooCell", for: indexPath) as! FooCell
let item = delegate.getItem(position: Int32(indexPath.row))!
// …
return cell
}
}
跨平台互操作性
由于app.cash.paging:paging-common
在JVM、iOS、Linux X64和macOS上通过类型别名委托给androidx.paging:paging-common
,因此在这些平台上使用app.cash.paging:paging-common
时,行为与androidx.paging:paging-common
完全一致。这意味着,如果你已经在使用AndroidX Paging,可以继续使用现有的代码库,无需进行任何重构即可开始使用Paging-Multiplatform。
版本管理
Paging-Multiplatform的版本号格式为X-Y
,其中:
•
X
是正在跟踪的AndroidX Paging版本。•
Y
是Paging-Multiplatform的版本。
例如,如果AndroidX Paging的版本是3.3.0-alpha02
,而Paging-Multiplatform的版本是0.5.1
,那么paging-common的发布版本将是app.cash.paging:paging-common:3.3.0-alpha02-0.5.1
。
实例--Github分页搜索功能
实例中使用了voyager作为导航框架,完整代码清单文件如下: ├── SearchApp.kt ├── models.kt ├── RepoSearch.kt ├── RepoSearchPresenter.kt ├── RepoSearchTheme.kt └── SearchField.kt
//SearchApp.kt
package com.calvin.box.movie
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import cafe.adriel.voyager.core.model.ScreenModel
import com.calvin.box.movie.xlab.paging.Event
import com.calvin.box.movie.xlab.paging.RepoSearchContent
import com.calvin.box.movie.xlab.paging.RepoSearchPresenter
import com.calvin.box.movie.xlab.paging.RepoSearchTheme
import com.calvin.box.movie.xlab.paging.ViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.core.screen.Screen
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
object SearchScreen : Screen {
@Composable
override fun Content() {
val screenModel = rememberScreenModel { SearchScreenModel() }
val viewModel by screenModel.viewModels.collectAsState()
RepoSearchTheme {
RepoSearchContent(
viewModel = viewModel,
onEvent = { event ->
screenModel.onEvent(event)
},
)
}
}
}
class SearchScreenModel : ScreenModel {
private val presenter = RepoSearchPresenter()
private val events = MutableSharedFlow<Event>(extraBufferCapacity = Int.MAX_VALUE)
private val _viewModels = MutableStateFlow<ViewModel>(ViewModel.Empty)
val viewModels = _viewModels.asStateFlow()
init {
screenModelScope.launch(Dispatchers.IO) {
_viewModels.emitAll(presenter.produceViewModels(events))
}
}
fun onEvent(event: Event) {
events.tryEmit(event)
}
}
@Composable
fun SearchApp() {
Navigator(SearchScreen)
}
//models.kt
import app.cash.paging.PagingData
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
sealed interface Event {
data class SearchTerm(
val searchTerm: String,
) : Event
}
sealed interface ViewModel {
data 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,
)
//RepoSearch.kt
mport androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paging.LoadStateError
import app.cash.paging.LoadStateLoading
import app.cash.paging.LoadStateNotLoading
import app.cash.paging.compose.LazyPagingItems
import app.cash.paging.compose.collectAsLazyPagingItems
import io.github.aakira.napier.Napier
@Composable
fun RepoSearchContent(
viewModel: ViewModel,
onEvent: (Event) -> Unit,
modifier: Modifier = Modifier,
) {
Napier.d { "RepoSearchContent invoke" }
when (viewModel) {
ViewModel.Empty -> {
Scaffold(
topBar = {
SearchField(
searchTerm = "",
onEvent = onEvent,
onRefreshList = {},
)
},
content = {},
modifier = modifier,
)
}
is ViewModel.SearchResults -> {
val repositories = viewModel.repositories.collectAsLazyPagingItems()
Scaffold(
topBar = {
SearchField(
searchTerm = viewModel.searchTerm,
onEvent = onEvent,
onRefreshList = { repositories.refresh() },
)
},
content = {
SearchResults(repositories)
},
modifier = modifier,
)
}
}
}
@Composable
private fun SearchResults(repositories: LazyPagingItems<Repository>) {
LazyColumn(
Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val loadState = repositories.loadState.refresh) {
LoadStateLoading -> {
item {
CircularProgressIndicator()
}
}
is LoadStateNotLoading -> {
items(repositories.itemCount) { index ->
val repository = repositories[index]
Row(Modifier.fillMaxWidth()) {
Text(
repository!!.fullName,
Modifier.weight(1f),
)
Text(repository.stargazersCount.toString())
}
}
}
is LoadStateError -> {
item {
Text(loadState.error.message!!)
}
}
else -> error("when should be exhaustive")
}
}
}
//SearchField.kt
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
@Composable
fun SearchField(
searchTerm: String,
onEvent: (Event) -> Unit,
onRefreshList: () -> Unit,
modifier: Modifier = Modifier,
) {
var textFieldValue by remember { mutableStateOf(TextFieldValue(searchTerm)) }
TextField(
textFieldValue,
onValueChange = { textFieldValue = it },
modifier
.wrapContentHeight()
.fillMaxWidth()
.padding(start = 16.dp, top = 16.dp, end = 16.dp),
placeholder = { Text("Search for a repository…") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
onEvent(Event.SearchTerm(textFieldValue.text))
onRefreshList()
},
),
singleLine = true,
)
}
//RepoSearchTheme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
@Composable
fun RepoSearchTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colors = if (darkTheme) darkColors() else lightColors(),
content = content,
)
}
//RepoSearchPresenter.kt
import app.cash.paging.Pager
import app.cash.paging.PagingConfig
import app.cash.paging.PagingSource
import app.cash.paging.PagingSourceLoadParams
import app.cash.paging.PagingSourceLoadResult
import app.cash.paging.PagingSourceLoadResultError
import app.cash.paging.PagingSourceLoadResultPage
import app.cash.paging.PagingState
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
class RepoSearchPresenter {
private val httpClient = HttpClient {
install(ContentNegotiation) {
val json = Json {
ignoreUnknownKeys = true
}
json(json)
}
}
private var latestSearchTerm = ""
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)
}
}
fun produceViewModels(events: Flow<Event>): Flow<ViewModel> {
return events.map { event ->
when (event) {
is Event.SearchTerm -> {
latestSearchTerm = event.searchTerm
if (event.searchTerm.isEmpty()) {
ViewModel.Empty
} else {
ViewModel.SearchResults(latestSearchTerm, pager.flow)
}
}
}
}
}
}
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
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>()
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
companion object {
/**
* The GitHub REST API uses [1-based page numbering](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#pagination).
*/
const val FIRST_PAGE_INDEX = 1
}
}
结语
Paging-Multiplatform通过为AndroidX Paging增加更多的Kotlin/Multiplatform目标,并为iOS提供UI组件,使得跨平台分页变得更加简单和高效。如果你正在开发一个需要在多个平台上共享分页逻辑的应用程序,不妨试试Paging-Multiplatform,它会让你的开发过程变得轻松愉快!
不管你是安卓大佬还是iOS大神,Paging-Multiplatform都能让你在分页的海洋中如鱼得水,快来试试吧!