语言切成英文,下拉刷新还写着中文:HarmonyOS 7 SwipeRefresherV2 ResourceStr 与并发收口实战
系统语言已经切成英文,菜谱列表、按钮和空状态都变了,只有下拉刷新区域仍然显示“正在加载中”。另一台小屏设备上,把中文直译成更长的提示后,文案又被截成半句。更麻烦的是,用户连续触发两次刷新,较慢的旧请求最后返回,竟然把较新的菜谱覆盖掉。
这三个现象分别对应三个不同层次的问题:提示文案没有进入资源系统、组件本身对长文本有截断边界、加载状态没有和请求代际绑定。只把 content 从中文字符串改成英文字符串,解决不了后两项。
HarmonyOS 26.0.0 Release 的 ArkUI API 差异清单显示,SwipeRefresherV2 的 content 属性从 string 扩展为 ResourceStr。当前参考文档进一步说明:组件从 26.0.0 开始提供,使用 @ComponentV2 和状态管理 V2,只能运行在 Stage 模型;content 可选、默认空字符串,文本超过列宽时会被截断;isLoading 是必填 @Param;Phone、PC/2in1、Tablet 和 TV 支持,Wearable 调用会出现接口未定义异常。
本文用两个不同场景完成接入:第一个把刷新提示改成可本地化 ResourceStr,并为长语言做宽度验收;第二个把 isLoading 与请求代际绑定,保证旧请求不能覆盖新结果。最后给出 72 组语言、宽度、字体缩放和请求结果矩阵,以及可复用的刷新状态证据文件。
当前本机只有 API 24 SDK,且没有连接 HDC 设备。宿主断言只验证资源键完整性、矩阵生成和“最新请求优先”状态模型,不声称已经完成 API 26 编译、SwipeRefresherV2 渲染、系统语言切换或真机网络时序验证。
先把 API 26 的变化读准确
API 26 Release 差异清单给出的不是一个新组件名称,而是一个容易被忽略的类型变化:
旧版本:content?: string 新版本:content?: ResourceStrResourceStr 的官方定义是 string | Resource。string 仍能传入普通字符串,Resource 则可以引用系统资源或应用资源中的字符串。也就是说,原来的写法不会因为类型升级立刻失效,但项目终于可以把刷新提示接入统一资源体系,不必在 ArkTS 里硬编码各语言。
SwipeRefresherV2 本身还有五条硬边界:
- 起始版本是 26.0.0,导入路径为 @kit.ArkUI。
- 组件使用 @ComponentV2,依赖状态管理 V2。
- 只支持 Stage 模型。
- content 超过列宽时会截断,ResourceStr 不会自动消除布局问题。
- 不建议直接设置通用属性或通用事件,因为工具链会生成Common节点,属性可能没有落到组件本身。
最后一条很关键。不要为了给刷新提示加宽度、点击事件或背景色,直接在 SwipeRefresherV2 后面堆通用属性;把布局和交互放到外层容器,组件只负责显示加载状态。
失败写法:把显示、网络和语言都塞进一个字符串
@Stateloading:boolean=false@Staterecipes:Recipe[]=[]privateasyncrefreshBad():Promise<void>{this.loading=truethis.recipes=awaitloadRecipes()this.loading=false}SwipeRefresherV2({content:'正在加载中',isLoading:this.loading})这段代码至少有四个问题:
- content 硬编码,无法随资源限定词切换。
- 没有记录哪一次请求拥有 loading 状态。
- 请求失败时如果没有 finally,loading 可能一直为 true。
- 两个请求并发时,旧请求可在最后覆盖新结果。
案例一:用 ResourceStr 接入多语言刷新提示
先把资源文件补齐
base 目录放默认中文:
{"string":[{"name":"refresh_loading","value":"正在更新菜谱"},{"name":"refresh_done","value":"菜谱已更新"},{"name":"refresh_failed","value":"更新失败,请稍后重试"}]}英语限定目录放对应文本:
{"string":[{"name":"refresh_loading","value":"Updating recipes"},{"name":"refresh_done","value":"Recipes updated"},{"name":"refresh_failed","value":"Update failed. Try again later"}]}资源目录名称和工程配置应以当前 DevEco Studio 与官方资源分类文档为准。这里展示的是资源键契约,不把目录示例当成已经在目标 SDK 编译通过的证据。
组件参数直接传 ResourceStr
import{SwipeRefresherV2}from'@kit.ArkUI'@ComponentV2struct RecipeRefreshIndicator{@ParamisLoading:boolean=false@Parammessage:ResourceStr=$r('app.string.refresh_loading')build(){Column(){SwipeRefresherV2({content:this.message,isLoading:this.isLoading})}.width('100%')}}宽度设置在外层 Column,而不是 SwipeRefresherV2 自身。这样避开Common节点带来的通用属性歧义。
不要把动态状态重新硬编码成字符串
如果业务需要根据阶段切换提示,可让状态只决定资源引用:
typeRefreshPhase='idle'|'loading'|'done'|'failed'functionmessageForPhase(phase:RefreshPhase):ResourceStr{switch(phase){case'loading':return$r('app.string.refresh_loading')case'done':return$r('app.string.refresh_done')case'failed':return$r('app.string.refresh_failed')default:return''}}状态机只返回资源引用,不在业务代码里维护中文、英文和其他语言的分支。
ResourceStr 不等于永不截断
官方文档明确说明,content 大于列宽时会截断。国际化验收不能只检查“资源是否切换”,还要检查各语言实际长度。
建议准备四档文案:
| 语言 | 加载提示 | 风险 |
|---|---|---|
| zh-CN | 正在更新菜谱 | 通常较短 |
| en-US | Updating recipes | 单词长度变化 |
| de-DE | Rezepte werden aktualisiert | 文案明显变长 |
| ar | 正在加载的阿拉伯语资源 | 方向与字形变化 |
阿拉伯语实际文本必须由语言资源提供并经过母语校对,不应在代码示例里用机器生成占位文本冒充正式翻译。
如果德语等长语言被截断,优先精简资源文案,而不是给组件强行设置通用宽度。刷新提示的目标是传达“正在处理”,不必承载完整帮助说明。
案例二:把 isLoading 绑定到请求代际
SwipeRefresherV2 只接收 isLoading,不会替应用管理网络并发。下面的协调器用递增代际标识每次刷新,只有最新请求可以提交结果和关闭加载状态。
interfaceRecipePage{items:Recipe[]cursor?:stringreceivedAt:number}interfaceRefreshEvidence{generation:numberstartedAt:numberfinishedAt?:numberoutcome:'running'|'accepted'|'stale'|'failed'itemCount?:numbererrorCode?:string}classRefreshCoordinator{privategeneration:number=0privaterecords:RefreshEvidence[]=[]begin():RefreshEvidence{constrecord:RefreshEvidence={generation:++this.generation,startedAt:Date.now(),outcome:'running'}this.records.push(record)returnrecord}isCurrent(generation:number):boolean{returngeneration===this.generation}finish(generation:number,outcome:RefreshEvidence['outcome'],itemCount?:number,errorCode?:string):void{constrecord=this.records.find((item)=>item.generation===generation)if(!record)returnrecord.finishedAt=Date.now()record.outcome=outcome record.itemCount=itemCount record.errorCode=errorCode}snapshot():RefreshEvidence[]{returnthis.records.map((item)=>({...item}))}}页面状态只接受当前代际:
@ComponentV2struct RecipeFeedPage{@Localphase:RefreshPhase='idle'@Localrecipes:Recipe[]=[]privatecoordinator:RefreshCoordinator=newRefreshCoordinator()privateasyncrefreshRecipes():Promise<void>{constrecord=this.coordinator.begin()this.phase='loading'try{constpage=awaitloadRecipes()if(!this.coordinator.isCurrent(record.generation)){this.coordinator.finish(record.generation,'stale',page.items.length)return}this.recipes=page.itemsthis.phase='done'this.coordinator.finish(record.generation,'accepted',page.items.length)}catch(error){if(!this.coordinator.isCurrent(record.generation)){this.coordinator.finish(record.generation,'stale')return}this.phase='failed'this.coordinator.finish(record.generation,'failed',undefined,classifyError(error))}}build(){Column(){RecipeRefreshIndicator({isLoading:this.phase==='loading',message:messageForPhase(this.phase)})List(){ForEach(this.recipes,(item:Recipe)=>{ListItem(){RecipeCard({recipe:item})}},(item:Recipe)=>item.id)}.layoutWeight(1)}}}示例省略了具体触发入口,因为 SwipeRefresherV2 参考只公开 content 和 isLoading 参数,没有公开一个可在这里凭空假设的 onRefresh 回调。项目可以由既有 Refresh 容器、页面手势或显式重试按钮调用 refreshRecipes,但触发能力必须以当前实际组件接口为准。
为什么旧请求不能顺手关闭 loading
时间线如下:
请求 1 开始:generation=1,isLoading=true 请求 2 开始:generation=2,isLoading=true 请求 2 先完成:提交新列表,phase=done 请求 1 后完成:标记 stale,不修改列表,也不修改 phase如果请求 1 的 finally 无条件执行 phase=‘idle’,就会覆盖请求 2 已经写好的 done 状态。加载状态的所有权必须和代际一致。
错误、空数据和成功要分开
刷新返回空数组可能是成功结果,不等于失败。建议把传输结果拆成明确联合类型:
typeRefreshResult=|{kind:'success';page:RecipePage}|{kind:'network-error';code:string}|{kind:'server-error';status:number}|{kind:'cancelled'}functionnextPhase(result:RefreshResult):RefreshPhase{if(result.kind==='success')return'done'if(result.kind==='cancelled')return'idle'return'failed'}这样可以避免把“没有新菜谱”错误地显示成“网络失败”。空结果仍可显示完成提示,同时保留原列表或显示业务定义的空态。
Wearable 必须在产品层提前分流
官方文档明确说明,SwipeRefresherV2 的实际支持设备为 Phone、PC/2in1、Tablet、TV,Wearable 调用会出现接口未定义异常。这个边界不该等运行时崩溃后才处理。
如果工程同时产出穿戴设备版本,应在产品构建、路由或组件装配层选择另一套加载指示器。不要在 Wearable 代码路径里先实例化 SwipeRefresherV2,再期待一个 try/catch 补救。
interfaceRefreshPresentationPolicy{product:'phone'|'tablet'|'pc'|'tv'|'wearable'useSwipeRefresherV2:booleanfallback:'none'|'progress-ring'|'inline-text'}functiondecideRefreshPresentation(product:RefreshPresentationPolicy['product']):RefreshPresentationPolicy{if(product==='wearable'){return{product,useSwipeRefresherV2:false,fallback:'progress-ring'}}return{product,useSwipeRefresherV2:true,fallback:'none'}}这是构建与装配策略模型,不是设备检测 API。实际工程应从受信任的产品配置获取 product,不要通过窗口宽度猜设备类型。
72 组回归矩阵怎么组成
矩阵覆盖四种语言、三档内容宽度、三档字体缩放和两种请求结果:
| 维度 | 取值 |
|---|---|
| 语言 | zh-CN、en-US、de-DE、ar |
| 宽度 | 320、480、720vp |
| 字体缩放 | 1.0、1.3、1.8 |
| 请求结果 | success、failure |
4 × 3 × 3 × 2 = 72。每条记录都需要资源键、实际解析文本、是否截断、isLoading 结束值、列表版本和截图名。
interfaceRefreshMatrixCase{id:stringlocale:'zh-CN'|'en-US'|'de-DE'|'ar'width:320|480|720fontScale:1|1.3|1.8outcome:'success'|'failure'resourceKey:'refresh_loading'}functionbuildRefreshMatrix():RefreshMatrixCase[]{constrows:RefreshMatrixCase[]=[]constlocales:RefreshMatrixCase['locale'][]=['zh-CN','en-US','de-DE','ar']constwidths:RefreshMatrixCase['width'][]=[320,480,720]constscales:RefreshMatrixCase['fontScale'][]=[1,1.3,1.8]constoutcomes:RefreshMatrixCase['outcome'][]=['success','failure']locales.forEach((locale)=>widths.forEach((width)=>{scales.forEach((fontScale)=>outcomes.forEach((outcome)=>{rows.push({id:['refresh-v2',locale,width,fontScale,outcome].join('__'),locale,width,fontScale,outcome,resourceKey:'refresh_loading'})}))}))returnrows}宿主断言覆盖什么
配套测试不运行 ArkUI,只检查纯逻辑:
- 生成 72 个唯一 caseId。
- 每种语言各有 18 条记录。
- 每档宽度各有 24 条记录。
- success 与 failure 各 36 条。
- 默认、完成、失败阶段都映射到确定资源键。
- 两次刷新乱序完成时,只有第二次结果被接受。
- 第一请求在第二请求之后完成时被标记 stale。
- Wearable 策略不会选择 SwipeRefresherV2。
- 非 Wearable 产品选择 SwipeRefresherV2。
宿主断言无法证明 ResourceStr 在目标系统正确解析,也无法证明德语文本是否被截断。
目标环境复现步骤
- 在 API 26 工程中确认使用 Stage 模型并导入 @kit.ArkUI。
- 为 refresh_loading、refresh_done、refresh_failed 准备默认资源与至少三种语言资源。
- 启动 zh-CN 环境,触发一次刷新,记录解析文本、组件宽度、字体缩放和截图。
- 切换 en-US、de-DE、ar,并按应用既有语言切换生命周期重新进入页面。
- 在 320、480、720vp 三档内容宽度下检查加载提示是否截断。
- 在 1.0、1.3、1.8 字体缩放下重复检查,不用缩小字号掩盖长文本。
- 让请求 1 延迟 1500ms,请求 2 延迟 200ms,确认请求 2 的列表保留,请求 1 标记 stale。
- 分别模拟成功、空数据、网络失败和取消,核对 phase 与 isLoading 最终状态。
- 检查 SwipeRefresherV2 没有直接挂通用属性或通用事件,布局由外层容器承担。
- 对 Wearable 构建确认未装配 SwipeRefresherV2,改用产品定义的替代指示器。
建议的证据文件
{"environment":{"systemVersion":"待填写","sdkVersion":"待填写","product":"phone","locale":"de-DE","fontScale":1.3,"widthVp":320},"caseId":"refresh-v2__de-DE__320__1.3__success","resource":{"key":"refresh_loading","resolvedText":null},"visual":{"truncated":null,"screenshot":"refresh-v2__de-DE__320__1.3__success.png"},"request":{"generation":null,"outcome":null,"accepted":null,"isLoadingAfter":null},"result":"pending"}未执行的字段保持 null,不能用 0 或 false 冒充实测。
常见误区
误区一:ResourceStr 会自动翻译普通字符串
不会。ResourceStr 允许传 string 或 Resource,硬编码 string 仍然是原字符串。国际化必须使用正确资源引用。
误区二:资源切换正确就不需要测宽度
官方已经说明长文本会截断。德语、阿拉伯语和大字体仍需要目标环境验收。
误区三:直接给 SwipeRefresherV2 加 width 和点击事件
通用属性或事件可能挂到额外生成的Common节点。把尺寸和交互放到外层容器更清楚。
误区四:任何请求结束都关闭 isLoading
只有当前代际拥有关闭权。旧请求结束后只能记录 stale,不能覆盖新请求状态。
误区五:所有设备都能使用
参考文档明确排除 Wearable。跨产品工程必须提前分流。
误区六:把宿主状态测试当作组件实测
宿主测试没有 ArkUI 组件、资源系统和目标设备,只证明状态机与矩阵数据正确。
文件级检查表
- target API 与组件起始版本已核对为 26.0.0。
- 工程使用 Stage 模型和 @ComponentV2 约束。
- content 使用 ResourceStr 资源引用,不在业务代码硬编码语言。
- refresh_loading、refresh_done、refresh_failed 的默认资源全部存在。
- 各语言资源键集合一致,没有缺键后静默回退。
- 320vp 与 1.8 字体缩放下检查过截断。
- SwipeRefresherV2 未直接挂不确定的通用属性或事件。
- isLoading 只有当前请求代际可以修改。
- 旧请求结果不会覆盖新列表。
- 空数据、失败、取消分别记录。
- Wearable 构建使用替代指示器。
- 宿主断言、API 26 编译、模拟器和真机证据分开记录。
证据边界与官方资料
API 26 Release 差异清单证明了 content 从 string 变为 ResourceStr。SwipeRefresherV2 参考证明了起始版本、Stage 模型、@ComponentV2、content 截断、isLoading 必填、Common提示和设备范围。ResourceStr 定义证明它是 string | Resource,并可引用系统或应用字符串资源。状态管理 V2 概览用于理解组件的状态模型边界。
- HarmonyOS 26.0.0 Release:ArkUI API 差异
- SwipeRefresherV2 组件参考
- ResourceStr 类型定义
- ArkTS 状态管理概览