本文记录 lazy-pagination-compose 接入 OpenHarmony 的完整过程,覆盖分页状态机盘点、composeMain 源码集隔离、真实引擎编译、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

lazy-pagination-compose 是基于 Compose Multiplatform 的分页组件库:“给你的 lazy 容器加个前缀就获得分页”——PaginatedLazyColumn 等 6 个组件在滚动到末尾时自动请求下一页,首屏/新页各有 loading / error / empty 三态插槽,refresh() 重置、retryLastFailedRequest() 重试。这 6 个组件底下跑的是一套纯 Kotlin 分页状态机(PaginationState + PaginationInternalState:Initial → Loading → Loaded/Error 的页 key 记账、appendPage 累计、Loading 期静默去重)。本次适配走社区已审核的 cmp_qrose 路线:状态机原样编译进鸿蒙,6 个 Compose 组件隔离到不参与 ohos 编译的 composeMain 源码集——上游的 Compose 1.8.2 / Kotlin 2.2 与社区 CMP 1.9.2 无法混链,而状态机零第三方依赖、天然可编。鸿蒙侧由 LazyPagination 入口驱动与 Compose 完全相同的状态迁移,ArkTS 用 List + LazyForEach + onReachEnd 复刻渲染层,真机上跑的就是上游那份状态机。

项目地址: AtomGit/oh-tpc/lazy-pagination-compose.git

上游仓库: Ahmad-Hamwi/lazy-pagination-compose

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

lazy-pagination-compose 为 Compose Multiplatform 应用提供"无限滚动分页"这一件事的完整答案:rememberPaginationState(initialPageKey) { key -> } 一行建状态,PaginatedLazyColumn(paginationState) 一个前缀把任意 LazyColumn 变成分页列表,取数层只需在回调里 appendPage(items, nextPageKey, isLastPage) 或 setError(exception)。6 个变体(Column / Row / VerticalGrid / HorizontalGrid / 两种 StaggeredGrid)共用同一套状态机,offset / cursor / time 等取数策略通吃。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套分页语义,但上游构建(Kotlin 2.2.0 / Compose 1.8.2)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以滚动加载,却无法证明上游分页状态机在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证状态机、去重短路、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 产物。
UI 依赖跨不过去6 个组件建立在 androidx.compose.foundation 上;社区鸿蒙 CMP 是 1.9.2,上游是 Compose 1.8.2 / Kotlin 2.2,混链不可维护。
状态机混着 Compose 运行时PaginationState 用了 mutableStateOf / @Stable,rememberPaginationState 是 @Composable——不剥离无法在无 Compose 工具链下编译。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
触发语义不能重写触底触发(key 回退链、Loading 短路、末页停止)是库的一部分,在 ArkTS 里重写等于换皮。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:分页状态机的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。分页语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和渲染列表。

1.2 库提供的能力

Demo 提供一个真实的分页会话,对应桥接的全部入口:

  • 建状态(init):等价 rememberPaginationState(0)(去掉 remember),桥内 LazyPagination.createState 建店;
  • 触发(request):等价 PaginatedLazyScrollable 里 LaunchedEffect 的触底触发——首屏请求与翻页请求走同一条 requestNewPage,返回 accepted 显式标注是否真的发起(Loading 去重 / 末页短路时为 false);
  • 追加(append):appendPage(items, nextPageKey, isLastPage) 累计条目并推进页 key;
  • 失败(fail):setError(exception) 进入 Error 态;
  • 重试(retry):retryLastFailedRequest() 用同一个页 key 重发;
  • 刷新(refresh):refresh() 回到 Initial;
  • 快照(snapshot):只读投影当前状态。

响应包含:

字段作用
ok / error操作是否成功,失败时 error 带原文(含 retry 在非法状态下的 require 信息)。
accepted本次 request 是否真的发起——Loading 去重与末页短路的显式化。
phaseinitial / loading / loaded / error,即 PaginationInternalState 四态公开投影。
items / itemCount / hasItems累计条目;hasItems 区分"首屏三态"(整页渲染)与"新页三态"(列表尾部)。
nextPageKey / requestedPageKey / isLastPage页 key 记账与末页标记。
onRequestPageCallsonRequestPage 回调触发次数,验收状态机确实回调了取数层。

引擎侧完整能力(去重短路、错误保留已加载数据、refresh 换初始 key、累计追加)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 8 项状态机交叉验证用例,证明 ohos .so 里执行的分页逻辑没有漂移。

1.3 实现适配

维度要求
代码复用状态机文件原样编译(PaginationState 仅剥 compose 注解与 mutableStateOf,不改任何分支语义);仅新增 1 个鸿蒙入口源文件 LazyPagination.kt(全仓唯一新增库源码)。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,sample/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);6 个 Compose 组件挪 composeMain、上游 UI 测试挪 composeTest(git mv 保留历史、零内容改动);sample/composeApp、sample/iosApp、publishing/、kotlin-js-store/ 留盘不进构建。
桥接稳定使用 1 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律状态机零第三方依赖,工具链直接钉社区已审核的 2.2.21-1.0.0 线(与 qrose 一致),不引入任何制品线问题。
UI 完整单页覆盖全部桥接入口:首屏三态、触底翻页、去重计数、新页错误重试、末页提示、刷新、可配置数据源。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙侧没有 PaginatedLazy* 组件本体,ArkTS Demo 用 List + LazyForEach + onReachEnd 复刻渲染行为,状态迁移全部来自 Native 侧的上游状态机。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点源码依赖边界(状态机 vs Compose 组件)、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:鸿蒙入口建模   ── PaginationState 剥离 compose、LazyPagination 入口、snapshot JSON 契约
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 配置芯片、状态行、分页列表、尾部三态与重试
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
lazy-pagination-compose/                库:commonMain 状态机 + composeMain 隔离源(进 ohos 构建)
lazy-pagination-compose/src/composeMain/ 上游 6 个 PaginatedLazy* + rememberPaginationState(不参与 ohos 编译)
lazy-pagination-compose/src/composeTest/ 上游 Compose UI 测试(不参与 ohos 编译)
sample/nativeApp/                       Kotlin/Native 动态库(Bridge + MiniJson)
sample/harmonyApp/                      DevEco Stage、CMake N-API 和 ArkTS 页面
sample/composeApp / sample/iosApp       上游多平台示例,留盘不进 settings
publishing/ kotlin-js-store/            上游发布脚本与 JS 锁文件,留盘不进构建
README.OpenHarmony.md / _CN.md          鸿蒙适配说明(中英双份)
README.upstream.md                      上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :lazy-pagination-compose 与 :sample:nativeApp,rootProject.name = "ohos_lazy-pagination-compose"。上游的 includeBuild("publishing")、AGP / compose 插件、Maven Central 发布流全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin 21.0.12.1,org.gradle.java.home 指向 C:\Users\Administrator\.jdks\jdk-21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 四件套替换,与审核通过的 cmp_qrose 字节级一致)
第三方依赖commonMain 零第三方依赖(状态机纯 Kotlin)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.lazypagination
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
文件依赖形态处置
PaginationInternalState.kt / LazyScrollableKeys.kt / ExperimentalPaginationApi.kt纯 Kotlin,零依赖原样编译(jvm + 双 ohos)
PaginationState.ktcompose runtime(mutableStateOf / @Stable / @Composable 工厂)剥离注解后编译(详见 2.3)
PaginatedLazy*.kt × 7androidx.compose.foundation 全家git mv → composeMain,零内容改动
commonTest/*.kt × 7compose material3 / uiTest / assertkgit mv → composeTest,零内容改动
sample/composeApp / sample/iosApp / publishing / kotlin-js-storeAGP / iOS / 发布 / JS 专属留盘不进构建

整库没有 expect/actual,没有 kotlinx 依赖——这是路线选择的关键前提:状态机不需要任何制品线验证,直接用社区已审核的 2.2.21 工具链就能编。KStore 需要先验证 kotlinx ohos 制品线再定路线,本库的制品可达性问题不存在,挡路的只有 compose runtime 依赖,而它恰好可以用"源码集隔离 + 剥注解"解决。

1.4 页面边界
标题区          LAZY-PAGINATION-COMPOSE · OPENHARMONY / 副标题(状态机跑在 Kotlin/Native · ArkTS 只负责渲染)
配置区          每页条数(5/10/20)· 总页数(1/2/3/8)· 失败策略(从不/第2页首败/总是)· 延时(300/1200/3000ms)
操作区          刷新 refresh() / 模拟触底 onReachEnd
状态行          phase / items / next / last / onRequestPage 计数 / 去重计数
列表区          List + LazyForEach(IDataSource) 渲染累计条目,onReachEnd 触发 request
尾部三态        LoadingProgress 转圈 / 红字错误 + 重试按钮 / — 没有更多了 —
首屏三态        !hasItems 时整页渲染 Initial 提示 / 转圈 / 错误 + 重试

页面启动时 aboutToAppear 即 init → request,对齐 CMP 里 Initial 状态由 LaunchedEffect 自动请求第一页的行为。

第 2 阶段:目标与依赖打通

2.1 工具链钉版(为什么是 2.2.21-1.0.0)

社区已审核的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 用的),配套 Gradle 8.14.1。本库上游是 Kotlin 2.2.0——2.2.21 与之同代,源码级兼容成本为零;状态机零依赖,没有 KStore 那种"coroutines 制品线反推 stdlib 2.4.20"的牵制。所以直接沿用审核线:Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(wrapper 的 gradlew / gradlew.bat / gradle-wrapper.jar / gradle-wrapper.properties 四件套直接从 cmp_qrose 复制,保证与已审核状态一致)。

settings.gradle.kts 把 eazytec 仓库写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_lazy-pagination-compose"

include(":lazy-pagination-compose")
include(":sample:nativeApp")

gradle.properties 钉版本与 JDK:

org.gradle.java.home=C:\\Users\\Administrator\\.jdks\\jdk-21
version=1.7.2-ohos.1
kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true
2.2 编译真实状态机

lazy-pagination-compose/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}

        commonTest.dependencies {
            implementation(libs.kotlin.test)   // 纯状态机测试,无 compose
        }
    }
}

androidTarget / ios×3 / macos×2 / js / wasmJs 目标与 androidLibrary / jetbrainsCompose / compose-compiler / lib.publishing 插件全部移出构建。实测:

./gradlew :lazy-pagination-compose:compileKotlinJvm            # BUILD SUCCESSFUL
./gradlew :lazy-pagination-compose:compileKotlinOhosArm64      # BUILD SUCCESSFUL
./gradlew :lazy-pagination-compose:compileKotlinOhosX64        # BUILD SUCCESSFUL
2.3 状态机的 compose 剥离(唯一动过的上游源码)

PaginationState 混着三类 compose 运行时依赖,处置各不相同:

依赖处置理由
@Stable 注解删除只是编译器提示,无行为
mutableStateOf<PaginationInternalState>换普通 var本 fork 不跑 Compose,无需可观察性;状态读写改直读
rememberPaginationState(@Composable)拆到 composeMain/RememberPaginationState.ktCompose 工厂留在隔离源里

剥离只动依赖注入点,不改任何分支与语义:setError 的 key 回退链、appendPageWithUpdates 的累计、requestPage 的 Loading 短路、refresh 的复位,逐行保持上游原样。这是全仓唯一修改内容的上游文件(7 个组件文件是 git mv 零改动)。

2.4 测试依赖的处置:上游测试是 Compose UI 测试

上游 commonTest 引 material3 / uiTest / assertk,无 Compose 工具链下必然编译失败。处置是三条纪律的组合:上游测试文件 git mv 到不注册的 composeTest 源码集零改动保留;新增纯 Kotlin 的 PaginationStateMachineTest(覆盖状态机全语义);根构建全局禁用 ohos 测试任务:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

第 3 阶段:桥协议建模

3.1 JSON 契约
io.github.ahmad_hamwi.compose.pagination/**(上游状态机,原样编译)
        ↓ PaginationState / PaginationInternalState / 去重短路
LazyPagination(新增鸿蒙入口)
        ↓ createState / requestNewPage / snapshot
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API lpcRequest()
        ↓
ArkUI Index.ets(单页分页列表)

请求与响应:

请求 : {"op":"init"}
       {"op":"request"}
       {"op":"append","items":["第 1 页 · Item 1"],"nextKey":1,"lastPage":false}
       {"op":"fail","message":"Network error"}
       {"op":"retry"} {"op":"refresh"} {"op":"snapshot"}
响应 : {"ok":true,"accepted":true,"phase":"loading","itemCount":10,"hasItems":true,
        "items":[...],"nextPageKey":1,"isLastPage":false,"requestedPageKey":1,
        "initialPageKey":0,"errorMessage":null,"onRequestPageCalls":2}
       {"ok":false,"error":"..."}

一个不对称设计:request 的 accepted 把"Loading 去重 / 末页短路"这两次静默 no-op 显式化——CMP 里 requestPage 遇 Loading 直接 return,调用方无感;桥把它变成布尔值,ArkTS 侧据此累加"去重计数"上屏,验收时肉眼可见。

3.2 触发语义为什么留在库里

requestNewPage 不是在 ArkTS 里重写的,而是把 PaginatedLazyScrollable 里 LaunchedEffect 的触发条件原样搬进库:

public fun <KEY, T> requestNewPage(state: PaginationState<KEY, T>): Boolean {
    val internal = state.internalState
    if (internal is PaginationInternalState.Loading) return false          // 去重短路
    if (internal is PaginationInternalState.Loaded && internal.isLastPage) return false

    val nextPageKey: KEY? = (internal as? PaginationInternalState.Loaded)?.nextPageKey
    val lastRequestedPageKey: KEY? =
        (internal as? PaginationInternalState.IHasRequestedPageKey<KEY>)?.requestedPageKey

    state.requestPage(
        initialPageKey = internal.initialPageKey,
        requestedPageKey = nextPageKey ?: lastRequestedPageKey ?: internal.initialPageKey,
        items = internal.items,
    )
    return true
}

key 回退链(nextPageKey → 上次请求 key → initialPageKey)与上游逐字一致——这段语义留在库里,ArkTS 侧就没有任何分页判断逻辑,只有"什么时候调、拿到结果怎么画"两件事。

3.3 错误通道

导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:

@CName("LpcRequest")
fun lpcRequest(request: String): String = try {
    /* op 分发 */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、未知 op、retry 在非 error/loading 状态(上游 require 抛出的完整信息)全部走同一个 ok:false 通道,ArkTS 侧统一渲染。

第 4 阶段:原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(liblpagination_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoslpagination.so)
  │ LpcRequest
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;ArkTS 里重写触发逻辑又会让语义漂移,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("LpcRequest")
fun lpcRequest(request: String): String   // init / request / append / fail / retry / refresh / snapshot 一口进
C ABI 符号ArkTS 方法覆盖内核
LpcRequestlpcRequest(req)createState + requestNewPage(含去重)+ appendPage / setError / retryLastFailedRequest / refresh / snapshot

配套导出 libohoslpagination_symbols(),其中 DisposeString 供 N-API 层归还 Kotlin 运行时字符串内存。

4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"lpcRequest", nullptr, NapiLpcRequest, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr}};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoslpagination_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoslpagination_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。

4.4 CMake 和类型声明
add_library(ohoslpagination SHARED IMPORTED)
set_target_properties(ohoslpagination PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(lpagination_napi SHARED napi_init.cpp)
target_link_libraries(lpagination_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoslpagination)

OHOS_ARCH 为空或 libohoslpagination.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const lpcRequest: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 触发与回填
aboutToAppear(): void {
  this.restart();     // init → request → scheduleFetch
}

private handleReachEnd(): void {
  const res = JSON.parse(lpcRequest(JSON.stringify({ op: 'request' }))) as LpcResult;
  if (res.ok === true && res.accepted === true) {
    this.applyResult(res);
    this.scheduleFetch();               // 模拟/真实取数
  } else {
    this.dedupCount += 1;               // 去重显式化
    this.applyResult(res);
  }
}

取数层在 ArkTS 侧(对齐上游 sample 的 DataSource.kt 语义):setTimeout 延时模拟网络、按失败策略决定 append 还是 fail、attemptsMap 让"第 2 页首败重试可成功"——accepted=true 才调度取数,回填后 applyResult 刷新全部 @State 与 LazyForEach 的 IDataSource。

5.2 页面交互

单页自上而下:标题区、四组配置芯片(每组不同选中色,改配置即 restart 重演)、操作按钮(刷新深黑 / 模拟触底灰)、状态行(phase / items / next / last / onRequestPage 计数 / 去重计数)、列表区。List 的 onReachEnd 等价上游"最后可见项到达末项"的触发;尾部 ListItem 按 phase 切换三态:LoadingProgress 转圈、红字错误 + 重试按钮、“— 没有更多了 —”。!hasItems 时整页渲染首屏三态(Initial 提示 / 转圈 / 错误 + 重试)。

5.3 对比度和错误边界

操作按钮深色底白字,错误态统一酒红 #B91C1C;ok:false 时错误原文进状态行,Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行与 hilog(LpcIndex / LpcNapi tag)。

第 6 阶段:示例与验证

6.1 工程结构
sample/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoslpagination)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/sample/nativeapp/
│       └── Bridge.kt                         @CName("LpcRequest") + MiniJson(含数组深度跟踪)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.lazypagination、versionName 1.7.2-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohoslpagination_api.h
        ├── src/main/cpp/types/liblpagination_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :lazy-pagination-compose:jvmTest                                   # 8 项交叉验证
./gradlew :sample:nativeApp:linkDebugSharedOhosArm64 :sample:nativeApp:linkDebugSharedOhosX64

cd sample/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。

hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.lazypagination -m entry

四、完整代码对照

4.1 整体架构

io.github.ahmad_hamwi.compose.pagination/**(上游状态机原样编译)
    │ PaginationState / PaginationInternalState / 去重短路 / key 回退链
LazyPagination.kt(新增鸿蒙入口)
    │ createState / requestNewPage / snapshot
Bridge.kt + MiniJson
    │ LpcRequest
liblpagination_napi.so
    │ lpcRequest
Index.ets
    │ JSON parse + @State 更新 + LazyForEach
    ▼
ArkUI 真机页面(单页分页列表)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_lazy-pagination-compose、多平台示例与发布构建移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 + kotlin-test,仅此两项
build.gradle.kts(根)版本 1.7.2-ohos.1、全局禁用 Ohos*Test* 任务
lazy-pagination-compose/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64
src/commonMain/.../LazyPagination.kt唯一新增库源文件(createState / requestNewPage / snapshot)
src/commonMain/.../PaginationState.kt唯一修改的上游源码(剥离 compose 注解与 mutableStateOf)
src/composeMain/**、src/composeTest/**上游组件与 UI 测试 git mv 隔离,零内容改动
src/commonTest/.../PaginationStateMachineTest.kt纯状态机交叉验证(8 用例)
sample/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 C ABI、七 op 分发、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(25 节)

4.3 关键 API 对照

层次API作用
引擎rememberPaginationState(0) { }建状态(鸿蒙侧 LazyPagination.createState)
引擎appendPage(items, nextPageKey, isLastPage)累计条目并推进页 key
引擎setError(exception)进 Error 态,保留已加载数据
引擎retryLastFailedRequest()同 key 重发(非 error/loading 状态 require 拒绝)
引擎refresh(initialPageKey)回 Initial,可换起始 key
引擎requestPage(internal)Loading 短路去重——accepted 的来源
入口LazyPagination.requestNewPage触底触发语义(key 回退链 + 双短路)
入口LazyPagination.snapshot四态 + 页 key + 错误信息只读投影
NativeLpcRequestC ABI JSON 导出(七 op)
N-APIlpcRequest(req)向 ArkTS 暴露能力
ArkTShandleReachEnd()onReachEnd → request → accepted 判定 → 调度取数

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(lpcRequest(JSON.stringify({ op: 'request' }))) as LpcResult;
// res.accepted === true:状态机已迁到 Loading,onRequestPage 已回调
// 取数完成后回填:
lpcRequest(JSON.stringify({
  op: 'append', items: page.items, nextKey: page.nextKey, lastPage: page.isLast
}));
"request" -> {
    accepted = LazyPagination.requestNewPage(state)   // 去重/末页短路在库里
}
"append" -> {
    state.appendPage(
        items = json.strList("items"),
        nextPageKey = json.int("nextKey", state.requestedPageKey ?: 0),
        isLastPage = json.bool("lastPage"),
    )
    accepted = true
}

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:状态机原样编译而不是 ArkTS 重写

上游 6 个组件的全部分页语义浓缩在 PaginationState 状态机里,且零第三方依赖——它本来就是"内核"。适配不提取、不重写:compose 依赖用"剥注解 + 源码集隔离"解决,状态机逐行上游原样编译。ArkTS 重写会让去重、key 回退、错误保留数据这些细节语义漂移,上游修 bug 也无法跟进。

决策 2:工具链钉 2.2.21-1.0.0 + Gradle 8.14.1(qrose 审核线)

上游 Kotlin 2.2.0 与 2.2.21 同代;状态机零依赖,没有制品线牵制,直接沿用社区已审核组合最稳。wrapper 四件套从 cmp_qrose 字节级复制,保证与审核状态一致。KStore 那种"依赖反推工具链"的场景在本库不存在——两条工具链线的经验不能互相套用。

决策 3:根构建替换(cmp_qrose 模式),多平台资产留盘

上游根构建(AGP / compose 插件 / includeBuild("publishing") Maven Central 发布流)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sample/composeApp、sample/iosApp、publishing/、kotlin-js-store/ 留盘不进 settings.gradle.kts——6 个组件源码也留(composeMain),社区 CMP 制品出现后可直接接回。

决策 4:触发语义进库,去重显式化

触底触发(nextPageKey ?: 上次请求 key ?: initialPageKey,Loading / 末页双短路)搬进 LazyPagination.requestNewPage 而不是写在 ArkTS 里;accepted 字段把 CMP 里的静默 no-op 变成可验收的布尔值,Demo 的"去重计数"直接可视化状态机行为。这是"内核没漂"最直观的验收面。

决策 5:jvmTest 交叉验证 + 上游 UI 测试零改动保留

状态机逻辑在 commonMain,JVM 与 ohos 跑同一份代码;8 项 jvmTest 是"内核没漂"的闸门。上游测试是 Compose UI 测试(material3 / uiTest),无 Compose 工具链下不可运行——处置不是改测试,而是"挪到不编译的 composeTest + 新增纯状态机测试 + 根构建禁用 Ohos*Test*",上游测试文件一个字节不动。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Temurin 21.0.12.1,C:\Users\Administrator\.jdks\jdk-21)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :lazy-pagination-compose:jvmTest

8 项用例全部通过(新增纯状态机测试,与 ArkTS Demo 的操作序列一一对应):

用例钉住什么
首屏 Initial → Loading → Loadedkey 记账、items 累计、nextPageKey
Loading 中重复触发被去重requestedKeys 不重复(accepted=false 的来源)
翻页请求 nextPageKey 且末页后停止isLastPage 短路
首屏 Error 可用同 key 重试retryLastFailedRequest 语义
新页 Error 保留已加载 itemshasItems=true 的错误态
refresh 回 Initial 并重新请求复位语义
refresh(initialPageKey=100) 换起始 key参数化复位
快照空字段一致性null 字段契约

6.3 HAP 验证

:lazy-pagination-compose:compileKotlinJvm / OhosArm64 / OhosX64: BUILD SUCCESSFUL
:lazy-pagination-compose:jvmTest: BUILD SUCCESSFUL(8 用例)
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
  → entry/libs/arm64-v8a/libohoslpagination.so ≈2.26MB、x86_64 ≈1.91MB(debugShared 未 strip)
llvm-nm -D: LpcRequest / libohoslpagination_symbols 均为全局 T 符号
CompileArkTS: finished
BuildNativeWithCmake: finished(双 ABI 编出 liblpagination_napi.so)
PackageHap: finished
install bundle successfully
start ability successfully

6.4 功能用例

  1. 启动即 init → request,首屏整页转圈约 1.2s 后出现"第 1 页 · Item 1…10",状态行 phase=loaded items=10 next=1 last=false onRequestPage=1;
  2. 滚到底或点"模拟触底"触发新页:尾部转圈 → 追加 Item 11…20;
  3. 连续快点"模拟触底":状态行出现"重复触发已去重(N)",onRequestPage 计数不涨——Loading 短路可见;
  4. 默认失败策略下第 2 页首次必败:尾部红字"模拟第 2 页加载失败(第 1 次尝试)"+ 重试按钮;
  5. 点重试 → 转圈 → 追加成功(对齐上游 sample 的 attemptsMap 语义);
  6. 第 3 页加载后尾部显示"— 没有更多了(isLastPage)—",再触底无请求;
  7. 点"刷新 refresh()"→ 状态回 Initial → 自动重新请求第一页,列表从头来过;
  8. 配置芯片切换页大小 / 总页数 / 失败策略 / 延时后 restart 重演,"总是失败"策略下可验收首屏错误 + 重试形态。

6.5 验证结论

分页状态机、去重短路、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的分页会话:首屏加载、触底翻页、去重、新页错误重试、末页停止与刷新闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/lazy-pagination-compose.git
cd lazy-pagination-compose
export JAVA_HOME="/path/to/jdk-21"
./gradlew :lazy-pagination-compose:jvmTest                       # 8 项交叉验证
./gradlew :sample:nativeApp:linkDebugSharedOhosArm64 :sample:nativeApp:linkDebugSharedOhosX64
cd sample/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.lazypagination -m entry
hdc shell hilog | grep -E "LpcNapi|LpcIndex"

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :lazy-pagination-compose 真正编译成 ohosArm64 / ohosX64 产物;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. org.gradle.java.home 只管 Gradle daemon,不管 gradlew 启动器 JVM——命令行必须先 export JAVA_HOME,否则报 JAVA_HOME is not set;
  4. JDK 路径因机而异:审核仓钉的是 Program Files\Eclipse Adoptium,本机实际在 C:\Users\Administrator\.jdks\jdk-21,gradle.properties 必须改成真实路径;
  5. PaginationState.requestPage 是 internal,跨模块(nativeApp)不可见——照 qrose 的 Qrose.kt 模式在库内新增 public 入口 LazyPagination,不要把访问修饰符直接改掉;
  6. MiniJson 顶层的逗号分割不认数组内逗号,append 的 items 数组会被截断——解析器必须加 [ / { 深度跟踪,只在深度 0 分割;
  7. Windows 上无法直接 ctypes 加载 OHOS ELF 冒烟(依赖 OHOS 动态链接器)——用 llvm-nm 验符号 + jvmTest 验逻辑双层替代;
  8. 上游 commonTest 引 compose material3 / uiTest,ohos 工具链下必挂——测试挪 composeTest(不注册不编译)+ 新增纯状态机测试 + 根构建禁用 Ohos*Test*;
  9. ArkTS 布局组件的间距写法要以已审仓库为准:Flex 用 Flex({ wrap: FlexWrap.Wrap }) + 子组件 .margin() 提供间距,Row / Column / List 才能直接传数字 space;
  10. KN CAdapter 生成代码有 1 条 clang -Wreturn-type 告警(api.cpp),cmp_qrose 同样存在,无害忽略;
  11. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  12. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();DevEco 只能打开 sample/harmonyApp 子目录,打开仓库根目录会 Gradle/hvigor 混淆 sync 失败。

8.2 已知问题

  • 鸿蒙侧没有 PaginatedLazy* 组件本体;UI 层需用 ArkUI List / Grid / WaterFlow 等价物自渲染(Demo 给了 List 参考实现);
  • 桥是单实例状态机(Demo 场景足够);同屏多列表并发需要把协议扩成 handle 制;
  • 上游 6 个容器变体在桥层不区分——容器差异本就属于 UI 层;
  • appendPageWithUpdates(实验 API)未在桥协议暴露,只保留 appendPage;
  • 上游 Compose UI 测试(composeTest)在鸿蒙工具链下不编译,回归靠 JVM 状态机测试 + Demo 手动验收;
  • refresh 之后状态是 Initial,桥协议保持显式——ArkTS 侧需自行再发一次 request(CMP 里由 LaunchedEffect 自动完成,Demo 已封装);
  • android / ios / macos / js / wasmJs 目标剥离(源文件留在树里),发布流一并剥离,本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.lazypagination 匹配的签名。

8.3 未来优化方向

  • 桥协议 handle 化,支持同屏多列表并发分页;
  • 补 Grid / WaterFlow(StaggeredGrid 等价物)变体 Demo,验收容器无关性;
  • 把 appendPageWithUpdates 补上桥接入口;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 lazy-pagination-compose(commonMain 状态机 + 6 个 Compose 组件)
    → 依赖盘点:状态机零依赖可编,组件依赖 Compose 必须隔离
    → 工具链对齐 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(qrose 审核线)
    → 根构建替换(cmp_qrose 模式,多平台资产留盘)
    → composeMain / composeTest 源码集隔离(git mv 零改动)
    → PaginationState 剥 compose 注解(唯一修改的上游源码)
    → LazyPagination 鸿蒙入口(触发语义进库)
    → C ABI(@CName × 1,七 op)+ accepted 去重显式化
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析 + List/LazyForEach/onReachEnd 复刻
    → ArkUI 真机单页验收(首屏三态 / 触底翻页 / 去重 / 重试 / 末页 / 刷新)

9.2 封装层次

  • 引擎层:上游原版 PaginationState、PaginationInternalState、去重短路与 key 回退链;
  • 入口层:新增 LazyPagination(createState / requestNewPage / snapshot),internal 语义的 public 化;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、JSON 解析、取数模拟、尾部三态与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 路线选择基于依赖形态而不是库类型:KStore 依赖 kotlinx 全家桶,制品线齐了就真编译整库;lazy-pagination-compose 的 UI 依赖 Compose(鸿蒙无配套制品),但状态机零依赖——"内核原样编译 + Compose 隔离"就是这个依赖形态下的正确答案。同一个"分页/存储"品类,路线可以完全不同;
  2. 语义留在库里,渲染留给平台:触底触发的 key 回退链和双短路搬进 LazyPagination.requestNewPage,ArkTS 侧没有任何分页判断——语义漂移的面被压到最小,accepted 字段还把静默去重变成了可验收的 UI 事实;
  3. 交叉验证是"内核没漂"的唯一可信证明:8 项 jvmTest 与 ArkTS Demo 的操作序列一一对应(同一条 request→append/fail 链),JVM 上等价于鸿蒙上;UI 类库的验收重点天然落在"滚到底真的多了一页、失败重试不丢数据"这些状态机行为上,而不是像素级对比。

9.4 适配成果

当前适配已完成:

  • lazy-pagination-compose 1.7.2 分页状态机的双 ABI Kotlin/Native 编译(上游状态机逐行原样,仅剥离 compose 注解;唯一新增库源文件 LazyPagination.kt);
  • 1 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • 七 op JSON 桥协议(init / request / append / fail / retry / refresh / snapshot),去重行为显式化;
  • DevEco Stage ArkUI 单页验收页面(首屏三态、触底翻页、去重计数、新页错误重试、末页提示、刷新、可配置数据源);
  • 8 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证(单个 鸿蒙适配 提交覆盖 60 个文件,含完整中英适配文档与 25 节中文主文档);
  • AtomGit 项目文档、OpenHarmony README(中英双份)和本地效果图。

适配过程 FAQ

以下问题来自 ohosArm64 目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。

Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?

  • 确认 DevEco Studio 版本与工程 build-profile.json5 的 compatibleSdkVersion 一致;
  • 在 File > Settings > Build, Execution, Deployment > Ohpm 配置可用的 ohpm 仓库镜像或代理;
  • 删除 oh_modules、oh-package-lock.json5 与 .hvigor 缓存后重新 Sync;
  • 公司网络需要代理时,同时给 ohpm 与 hvigor 配置 http.proxy。

Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?

  • 先在 example 目录执行 ./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用 linkDebugSharedOhosX64);
  • 确认 libxxx.so 与 libc++_shared.so 已复制到 ohos/entry/libs/<abi>/;
  • 检查 entry/build-profile.json5 的 abiFilters 与设备 ABI 是否一致(真机 arm64-v8a,模拟器 x86_64);
  • 清理 entry/.cxx 与 entry/build 后重试。

Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?

  • 确认 CMake 的 add_library(<target> SHARED ...) 名称与 import ... from 'libxxx_napi.so' 完全一致;
  • 解包 HAP,确认 libs/<abi>/libxxx_napi.so 已打包;
  • 删除 entry/build、entry/.cxx 与 oh_modules 后重新构建;
  • 检查 module.json5 的 srcEntry 与页面路径是否正确。

Q4:真机安装报 signature verification failed 或 install failed?

  • 在 DevEco Studio 的 Signing Configs 配置证书、Profile 与密码;
  • bundleName 必须与签名 profile 中的 bundle name 一致;
  • 设备需加入 profile 的 deviceIds,调试证书有效期需覆盖当前时间;
  • 用 hdc list targets 确认设备在线,再执行 hdc install -r xxx-signed.hap。

Q5:应用启动白屏、闪退或 NAPI 调用崩溃?

  • 用 hdc hilog | grep -i "napi\|kotlin" 查看原生日志;
  • 确认 libc++_shared.so 与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致;
  • 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用 XxxFree 释放;
  • 在 DevEco 的 Native 调试配置中附加 lldb,定位空指针与越界访问。

Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?

  • 使用鸿蒙发行版 Kotlin(如 2.2.21-1.0.0),并在 settings.gradle.kts 配置 eazytec 仓库;
  • Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
  • 确认 kotlin { ohosArm64() } 已应用,且 local.properties 的 SDK 路径有效;
  • 清理 ~/.gradle/caches 中对应版本的 Kotlin/Native 缓存后重试。

Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?

  • 对齐 DevEco Studio、HarmonyOS SDK 与 build-profile.json5 中的 compatibleSdkVersion;
  • 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免 modelVersion 与 IDE 不匹配;
  • 命令行构建时设置 DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录。

Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?

  • 使用 DevEco 自带的 node 与 hvigor:
    "$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...;
  • 设置 JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME;
  • 加上 --no-daemon 排除守护进程缓存影响,并清理 entry/build 与 .hvigor。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐