【HarmonyOS 7新能力|047】ModularObjectExtensionAbility工程封装:把接入逻辑放进可维护的分层结构

跨应用调用最容易写成"拿到对象后直接调用",也最容易在版本升级、权限变化和异常恢复时失控。真正困难的并不是把一次请求发出去,而是明确能力由谁提供、调用方可以依赖什么、对象失效后如何恢复,以及失败是否允许重试。本文不把某个接口名称当成完整方案,而是围绕 ModularObjectExtensionAbility 所代表的模块化对象扩展思路,给出一套可落地的工程封装方法。示例采用 ArkTS 风格的领域接口,具体系统 API、权限名和配置项应以当前 SDK 与华为开发者文档为准。
一、先划清能力边界:跨应用调用不是普通函数调用
进程内函数共享同一份内存、类型和生命周期;跨应用能力调用则跨越了身份、进程、版本与资源边界。调用方看到的"对象"更适合被理解为一个受约束的能力入口,而不是可以永久持有的本地实例。只要把这一点想清楚,超时、死亡重连、权限拒绝和协议演进就会自然进入设计范围。
建议先把需求写成能力契约:输入是什么、输出是什么、是否产生副作用、是否可取消、失败后能否安全重试。不要让页面直接认识底层连接对象,也不要把系统返回码散落到业务代码中。
ts
export interface DocumentPreviewRequest {
requestId: string
uri: string
maxWidth: number
}
export interface DocumentPreviewResult {
requestId: string
thumbnailUri: string
generatedAt: number
}
这份契约只表达业务事实,不携带页面组件、Context 或平台连接句柄,因此可以单测,也方便未来替换实现。
二、用四层结构隔离平台变化

推荐把工程拆成四层。第一层是页面和 ViewModel,只负责用户输入、加载状态与结果展示;第二层是能力网关,对外提供稳定方法;第三层是连接与扩展适配器,处理发现、绑定、调用和断开;第四层是安全、日志、缓存等基础设施。依赖方向只能向下,底层不得反向操作页面。
ts
export interface PreviewGateway {
createPreview(input: DocumentPreviewRequest): Promise<DocumentPreviewResult>
cancel(requestId: string): Promise<void>
}
export class PreviewViewModel {
constructor(private readonly gateway: PreviewGateway) {}
async load(input: DocumentPreviewRequest): Promise<DocumentPreviewResult> {
return this.gateway.createPreview(input)
}
}
这种结构的价值不在于文件夹数量,而在于让业务代码只依赖 PreviewGateway。即使平台侧对象获取方式发生变化,页面与领域模型也无需跟着重写。
三、契约必须可演进,而不是一次性 DTO
跨应用两端未必同时升级。若新增字段就破坏旧调用方,模块化很快会变成版本锁死。请求中应保留明确的协议版本、可选能力和可忽略扩展;响应则应使用稳定错误码,不把底层异常字符串当成公开协议。
ts
export interface CapabilityEnvelope<T> {
protocolVersion: number
capability: string
traceId: string
payload: T
options?: Record<string, string>
}
export const PREVIEW_PROTOCOL = 1
协议升级遵循"新增优先、删除谨慎"。新提供方应能处理旧版本请求;旧提供方遇到无法理解的新版本时返回明确的"不支持版本",让调用方降级,而不是崩溃或静默产出错误数据。
四、把发现、连接与调用做成状态机

连接过程不是一个布尔值。真实状态至少包括空闲、发现中、连接中、可用、恢复中和已关闭。把它们压成 connected=true/false 会导致并发点击重复建连,也无法区分用户主动关闭与意外断开。
ts
export type LinkState =
| 'idle'
| 'discovering'
| 'connecting'
| 'ready'
| 'recovering'
| 'closed'
export interface CapabilityLink {
state(): LinkState
connect(): Promise<void>
close(): Promise<void>
}
网关应复用同一个进行中的连接 Promise,让多个并发请求等待同一轮建连。进入 closed 后不再隐式恢复,避免页面退出后后台又悄悄拉起能力。
五、身份与权限校验必须在提供端再次执行
调用方隐藏按钮只能改善体验,不能构成安全边界。提供端接到每一次请求时都要重新判断调用身份、能力范围、参数合法性和资源归属。尤其是 URI、文件路径、账号标识等字段,不能因为来自"已连接对象"就默认可信。
ts
export interface CallPrincipal {
callerId: string
permissions: string[]
}
export class AccessPolicy {
assertPreviewAllowed(principal: CallPrincipal, uri: string): void {
if (!principal.permissions.includes('document.preview')) {
throw new DomainError('PERMISSION_DENIED', '当前调用方无预览权限')
}
if (!uri.startsWith('file://') && !uri.startsWith('datashare://')) {
throw new DomainError('INVALID_URI', '不支持的资源地址')
}
}
}
示例中的权限字符串只是领域表达,并非系统权限声明。工程中应把平台鉴权结果映射为领域权限,并确保清单、运行时授权、隐私说明和实际行为一致。
六、生命周期要绑定到业务会话
扩展能力可能被系统回收,调用方页面也可能进入后台或销毁。如果把远端对象放进全局单例长期持有,容易积累监听器、泄漏资源,甚至在错误账号下复用旧会话。更稳妥的做法是让连接由一个明确的业务会话拥有。
ts
export class PreviewSession {
private disposed = false
constructor(private readonly link: CapabilityLink) {}
async start(): Promise<void> {
if (this.disposed) throw new DomainError('SESSION_CLOSED', '会话已结束')
await this.link.connect()
}
async dispose(): Promise<void> {
if (this.disposed) return
this.disposed = true
await this.link.close()
}
}
页面退出、账号切换或业务流程结束时显式释放。意外断开只触发有限恢复,不应无限重连,更不能绕开用户可见的状态提示。
七、错误模型要让上层知道下一步怎么做
"调用失败"对用户没有帮助,对代码也无法决策。至少应区分不可用、权限拒绝、版本不兼容、参数错误、超时、提供端繁忙和内部错误。每类错误都应给出是否可重试、是否需要用户操作以及是否允许降级。
ts
export type DomainErrorCode =
| 'CAPABILITY_UNAVAILABLE'
| 'PERMISSION_DENIED'
| 'VERSION_UNSUPPORTED'
| 'INVALID_ARGUMENT'
| 'TIMEOUT'
| 'BUSY'
| 'INTERNAL'
| 'SESSION_CLOSED'
| 'INVALID_URI'
export class DomainError extends Error {
constructor(
readonly code: DomainErrorCode,
message: string,
readonly retryable: boolean = false
) { super(message) }
}
适配器负责把系统错误转换成领域错误;页面只根据领域错误决定展示"重试""去授权"还是本地降级,避免 UI 依赖不稳定的错误文案。
八、重试之前先解决幂等与超时
跨进程超时不代表提供端没有执行。调用方如果直接重发"创建、支付、删除"等有副作用动作,可能造成重复结果。每个变更请求都应携带稳定 requestId,提供端在合理时间窗内缓存已完成结果,重复请求返回同一结果。
ts
export class RetryPolicy {
async run<T>(action: () => Promise<T>, maxAttempts = 2): Promise<T> {
let last: Error | undefined
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { return await action() } catch (error) {
last = error as Error
const retryable = error instanceof DomainError && error.retryable
if (!retryable || attempt === maxAttempts) throw error
}
}
throw last ?? new Error('unknown error')
}
}
只有明确标记为可重试的瞬态错误才能进入重试,而且次数有限、带退避。权限、参数和协议错误重试没有意义,应立即返回。
九、并发控制应放在能力网关而不是按钮上
按钮防抖不能阻止多个页面、后台任务或重复回调同时访问同一能力。网关需要明确并发策略:只读操作可以并行,写操作按资源串行,相同请求可以合并,已取消请求不得覆盖新结果。
ts
export class RequestRegistry {
private readonly running = new Map<string, Promise<unknown>>()
coalesce<T>(key: string, factory: () => Promise<T>): Promise<T> {
const existing = this.running.get(key)
if (existing) return existing as Promise<T>
const task = factory().finally(() => this.running.delete(key))
this.running.set(key, task)
return task
}
}
请求合并适合相同资源的只读查询。涉及写入时应使用资源级队列并保留顺序,不能为了"提速"盲目合并不同意图。
十、可观测性要串起调用两端
线上问题经常表现为"偶尔没反应"。没有统一 traceId,就无法判断时间消耗在发现、连接、鉴权、执行还是回调。每次调用应记录阶段、耗时、结果码和协议版本,但不得记录令牌、原始文件内容等敏感数据。
ts
export interface CallMetric {
traceId: string
capability: string
stage: 'discover' | 'connect' | 'authorize' | 'execute' | 'respond'
durationMs: number
result: 'ok' | 'error' | 'cancelled'
errorCode?: DomainErrorCode
}
调用方和提供方使用同一 traceId,各自记录本段耗时。聚合后可以看到成功率、P95 延迟、断连率和版本分布,也能为降级阈值提供证据。
十一、用假实现与故障注入覆盖边界
跨应用能力不应等到真机联调才测试。先为网关提供内存假实现,覆盖成功、超时、断开、权限拒绝和旧协议响应;再做平台适配层集成测试,最后验证真实设备上的生命周期与权限路径。
ts
export class FakePreviewGateway implements PreviewGateway {
constructor(private readonly mode: 'success' | 'timeout' | 'denied') {}
async createPreview(input: DocumentPreviewRequest): Promise<DocumentPreviewResult> {
if (this.mode === 'denied') {
throw new DomainError('PERMISSION_DENIED', '测试拒绝')
}
if (this.mode === 'timeout') {
throw new DomainError('TIMEOUT', '测试超时', true)
}
return { requestId: input.requestId, thumbnailUri: 'memory://preview', generatedAt: 1 }
}
async cancel(_requestId: string): Promise<void> {}
}
重点断言用户可见状态和资源释放:重复点击是否只执行一次、页面退出后是否取消、断开后旧结果是否被丢弃、恢复后是否继续使用正确账号。
十二、落地清单:从"能调用"升级为"可维护"
上线前逐项确认:能力契约与副作用已说明;协议版本可兼容;页面不持有平台对象;连接状态机可回收;提供端重新鉴权;输入经过校验;错误可行动;写请求具备幂等键;重试次数有限;并发策略明确;日志不含敏感数据;断连和升级路径已测试。
ts
export const releaseChecklist = {
contractVersioned: true,
providerAuthorization: true,
lifecycleBounded: true,
idempotencyEnabled: true,
retryBounded: true,
traceConnected: true,
sensitiveLogsRemoved: true,
failurePathsTested: true
}
ModularObjectExtensionAbility 的工程价值,不只是让对象跨越应用边界,而是让能力以契约化、可治理的方式被复用。把发现、连接、鉴权、协议、生命周期和观测都收进网关与适配层后,业务页面才能保持简单,平台升级和异常恢复也不会扩散成全工程重构。真正可靠的跨应用能力,应该在成功路径顺畅的同时,也对拒绝、断开、超时与版本差异有清晰答案。