主题
HarmonyOS 空白工程接入晞晗IM
这份文档面向 ArkTS + ArkUI Stage 工程。正式交付形态是一个 HAR/OHPM 包:
text
@xihansoftware/xhim
├── XHIMClient Promise Facade
├── ArkUI 基础组件
├── resources/media Lucide SVG
└── libs/arm64-v8a
├── libxhim_napi.so
└── libxhim_core_v1.so本文只处理 HarmonyOS 客户端。Development 联调只需要 Server URL 和已创建的 测试 User ID、Conversation ID 和 App ID;Production 还需要业务后端提供用户 鉴权回调。公开部署配置由 SDK 自动发现。客户端不需要数据库密码、服务端密钥、 C++ 工具链或手工 Token。服务端操作仅见 XHIM Server 文档。
跑通第一条消息后,所有公开方法、事件、模型和错误请查 SDK API Reference。
最短路径是“添加 HAR → 配置网络权限 → XHIMClient.connect → sendText”。
先确认发行包
只使用 Release Manifest 对应的签名 HAR/OHPM 包。发行包必须包含 libxhim_napi.so、libxhim_core_v1.so、资源、类型声明、checksum、LICENSE、 NOTICE 和 SBOM;只复制 Index.ets 不能连接真实 Server。
1. 新建工程并添加 HAR
- DevEco Studio 选择
Create Project → Empty Ability; - 使用 ArkTS、Stage 模型,最低 API 以发布清单为准(附件 Picker 目标 API 11+);
- 把
xhim.har放入应用模块的libs/; - 在模块
oh-package.json5添加:
json5
{
"dependencies": {
"@xihansoftware/xhim": "file:./libs/xhim.har"
}
}企业 OHPM 发布后改为固定版本:
json5
"@xihansoftware/xhim": "<version>"执行 ohpm install,确认 HAR、libxhim_napi.so 和 libxhim_core_v1.so 都进入最终 HAP。
2. 网络权限
模块 module.json5:
json5
{
"module": {
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" },
{ "name": "ohos.permission.GET_NETWORK_INFO" }
]
}
}Production 只连接由服务端签名且经过 SDK 验证的 HTTPS/WSS Endpoint;客户的 域名或 IP 通过 DeploymentConfig 和 Endpoint Bundle 配置,不需要重新编译 HAR。ArkTS 业务层不能传入“忽略证书”选项。局域网明文模式只属于独立 Development HAR。
3. 一行创建并登录
Development Server 已显式开启免密码登录时:
typescript
import {
XHIMClient
} from '@xihansoftware/xhim'
const client = await XHIMClient.connect(
getContext(this),
'http://192.168.1.20:8080',
'alice'
)connect 映射到底层最小配置:
| Core 配置 | HarmonyOS 来源 |
|---|---|
appId | options.appId,否则 Bootstrap 默认值 |
storagePath | options.storageRootPath 下按 App ID + User ID 隔离 |
deployment.bootstrapUrl | server |
| Endpoint Key ID + Public Key | Bootstrap 公开配置 |
connect 自动完成以下工作:
- 只用
GET /v1/sdk/config获取公开 App ID 和 Endpoint 验签公钥; - 为
App ID + User ID创建不可猜测的账号隔离数据库目录; - 创建并启动 Core;
- Development 模式完成测试用户登录;
- 自动保持登录状态并处理底层续期。
Production 不允许仅凭 User ID 登录。把宿主业务 Session 封装成一个鉴权回调, 页面和 ViewModel 不处理登录票据:
typescript
import {
XHIMAuthentication,
XHIMClient
} from '@xihansoftware/xhim'
const client = await XHIMClient.connect(
getContext(this),
'https://im.customer.example',
currentUserId,
{
authentication: XHIMAuthentication.business(
async (): Promise<string> =>
businessBackend.issueXHIMCredential()
)
}
)新项目使用业务 Provider,不把固定登录票据写入源码或配置。生产客户端 不会调用 Development 登录接口。
Server URL 必须是无 userinfo、query、fragment 的绝对 HTTP(S) URL。系统 HTTP 客户端禁止重定向并把响应体限制为 256 KiB;HTTP 仅在公开配置明确声明 environment=development 时接受。具体最低 API 以发行清单为准。
App 的账号 Session/Store 持有 Client;Page/Component 销毁不等于账号退出。 用户退出时显式 logout();它会撤销自动续凭 Provider,并用 generation fence 阻止旧账号的在途刷新结果提交。进程会话结束时 shutdown()。同一个 HAR 可 连接不同客户域名,实际 API/WSS/上传/下载地址仍由服务端签名发现。
Production Release 不允许 Local Preview、Development Easy Login、固定测试 账号或匿名降级。Provider 使用宿主已有业务登录态向购买方自己的后端取得 XHIM 登录票据,其余生命周期由 SDK 处理。
3.1 状态、会话和首条消息
typescript
import {
XHIMEvent,
XHIMEventType,
XHIMMessage,
XHIMMessageState
} from '@xihansoftware/xhim'
import { util } from '@kit.ArkTS'
const unsubscribe = client.onEvent((event: XHIMEvent): void => {
if (event.type === XHIMEventType.STATE_CHANGED) {
console.info(`XHIM state: ${event.state}`)
}
})
const conversationId = 'xhim-demo-direct'
const clientMessageId = util.generateRandomUUID()
await client.sendText(
conversationId,
'Hello from HarmonyOS',
clientMessageId
)
const conversations = await client.conversations(undefined, 50)
const page = await client.messages(conversationId, undefined, 50)
let through: number = 0
page.messages.forEach((message: XHIMMessage): void => {
if (message.serverSequence !== undefined &&
message.serverSequence > through) {
through = message.serverSequence
}
})
if (through > 0) {
await client.markConversationRead(conversationId, through)
}
const persisted = await client.message(clientMessageId)
if (persisted.state === XHIMMessageState.FAILED ||
persisted.state === XHIMMessageState.CANCELLED) {
await client.retryMessage(clientMessageId)
}发送成功表示可靠 Outbox 已受理;最终状态以 SERVER_ACCEPTED 为准。 nextCursor 是不透明 ArrayBuffer,下一页只能原样传回对应查询。账号退出时 调用 unsubscribe()。
在线状态与“正在输入”
Client 登录完成后直接调用,不需要 ArkUI 页面处理 Token、HTTP 或 WebSocket:
typescript
import { XHIMPresenceStatus } from '@xihansoftware/xhim'
await client.publishPresence(XHIMPresenceStatus.ONLINE)
await client.publishTyping(conversationId, true)
// 输入框清空、发送成功或页面退出时立即清除。
await client.publishTyping(conversationId, false)在已有 onEvent 回调中处理 PRESENCE_CHANGED 和 TYPING_CHANGED,分别读取 presenceUpdate 与 typingUpdate。只在开始/停止输入时上报,不要每次按键 调用;页面必须按 expiresAtMilliseconds 自动清除过期显示。TTL 省略时由 服务端使用统一默认值。
3.2 服务端历史与账号视图
typescript
const history = await client.getMessageHistory(
conversationId,
undefined,
50
)
if (history.continuation !== undefined) {
await client.getMessageHistory(
conversationId,
history.continuation,
50
)
}
const first = history.messages[0]
if (first !== undefined && first.serverMessageId !== undefined) {
await client.deleteMessageForSelf(
conversationId,
first.serverMessageId,
util.generateRandomUUID()
)
}
if (history.latestServerSequence > 0) {
const cleared = await client.clearConversation(
conversationId,
history.latestServerSequence,
util.generateRandomUUID(),
history.view?.revision ?? 0
)
await client.hideConversation(
conversationId,
util.generateRandomUUID(),
cleared.view.revision
)
}历史按服务端序号从新到旧返回,continuation 只能原样传回。删除、清空和隐藏 只影响当前账号;同一次失败重试必须复用原 mutationId。下一次清空/隐藏用 最新 view.revision 做 CAS。需要取消时通过每个方法最后的 onRequest 参数取得 requestId,再调用 client.cancelRequest(requestId)。
3.3 用户资料、单聊、Push 和请求取消
typescript
import {
XHIMPushDevice,
XHIMPushPlatform
} from '@xihansoftware/xhim'
const me = await client.currentUserProfile()
const profiles = await client.userProfiles([me.userId, 'bob'])
// undefined 表示保持不变;空字符串表示明确清空。
await client.updateCurrentUserProfile({
displayName: 'Alice HarmonyOS',
avatarUrl: undefined,
bio: ''
})
const direct = await client.directConversation('bob')
await client.sendText(
direct.conversationId,
'Hello from HarmonyOS'
)从 Push Kit 回调取得 token 后,使用 HUKS/首选项中持久化的安装级 deviceId 注册。输入对象的 toString() 会脱敏,回执不包含 token;业务日志仍不得记录 provider token:
typescript
await client.registerPushDevice(
new XHIMPushDevice(
XHIMPushPlatform.HUAWEI,
pushInstallationId,
providerToken,
'production',
'zh-CN'
)
)
// 退出账号或关闭推送:
await client.disablePushDevice(pushInstallationId)HarmonyOS 通过 observer 取得 native requestId,再调用通用取消。取消只影响 本次等待,不会关闭账号级 Client:
typescript
let lookupRequestId: bigint | undefined = undefined
const lookup = client.userProfiles(
['alice', 'bob'],
(requestId: bigint): void => {
lookupRequestId = requestId
}
)
if (lookupRequestId !== undefined) {
client.cancelRequest(lookupRequestId)
}
try {
await lookup
} catch (_) {
// 本次查询已取消,账号级 client 仍然可用。
}4. 加入 ArkUI 聊天组件
typescript
import {
XHIMChatView,
MessageItem
} from '@xihansoftware/xhim'
XHIMChatView({
messages: this.messages,
onSend: (text: string) => this.viewModel.send(text),
onAttachment: () => this.viewModel.chooseAttachment()
})组件使用 XHIM 色彩基线和 Lucide SVG,只通过 onSend/onAttachment 回传用户 动作。ViewModel 调用:
typescript
await client.sendText(conversationId, text)
const page = await client.messages(conversationId, undefined, 50)编辑与撤回只接受已有 serverMessageId 的服务端消息。expectedRevision 使用当前消息的 mutationRevision,同一次重试必须复用同一个 mutationId:
typescript
import { util } from '@kit.ArkTS'
if (message.serverMessageId !== undefined) {
const edited = await client.editText(
message.conversationId,
message.serverMessageId,
util.generateRandomUUID(),
message.mutationRevision,
'修改后的内容'
)
await client.recall(
edited.conversationId,
message.serverMessageId,
util.generateRandomUUID(),
edited.mutationRevision
)
}返回值是服务端确认后的完整 XHIMMessage。监听 XHIMEventType.MESSAGE_MUTATED 后刷新时间线;未知枚举保留在 nativeMutationKind。需要取消时通过最后一个 onRequest 参数拿到 requestId,再调用 client.cancelRequest(requestId)。
页面不要轮询或读取 SQLite。账号级 ViewModel 建立事件驱动重查:
typescript
const projectionRefresh = new XHIMProjectionRequeryController(
client,
async (_change?: XHIMProjectionChange): Promise<void> => {
await reloadTimelineFromXHIM()
}
)
projectionRefresh.start()
// 页面/账号容器销毁时 projectionRefresh.stop()投影事件是可合并的失效通知,包含 origin、scope、IDs、revision 和 sequence; 未知 kind/schema 产生 PROJECTION_INVALIDATED,需要宽范围重新调用 SDK 查询。
置顶和免打扰使用服务端 CAS,同步版本来自 conversation.preferenceRevision;草稿只保存到当前账号的加密本地库:
typescript
await client.setConversationPreference(
conversation.conversationId,
true,
false,
util.generateRandomUUID(),
conversation.preferenceRevision
)
const text = '尚未发送的内容'
await client.setLocalDraft(conversation.conversationId, {
contentType: 'text/plain',
contentVersion: 1,
payload: new util.TextEncoder().encodeInto(text).buffer,
fallbackText: text
})
const draft = await client.localDraft(conversation.conversationId)
await client.clearLocalDraft(conversation.conversationId)draft.isPresent === false 表示没有草稿;本地草稿不会跨设备同步。
再把 Facade 消息映射为 MessageItem。不要在 ArkUI Component 里直接打开 SQLite 或调用 Node-API native 对象。
5. 拍照、相册、视频和文件
在 Page/ViewModel 中创建系统 Picker:
typescript
import {
XHIMAttachmentPicker,
XHIMAttachmentKind,
XHIMMediaMessaging
} from '@xihansoftware/xhim'
const picker = new XHIMAttachmentPicker(getContext(this))
const photo = await picker.takePhoto()
const image = await picker.pickImage()
const video = await picker.pickVideo()
const file = await picker.pickFile()Picker 只返回临时授权的 localUri 和 MIME。先将内容复制到账号隔离的持久 沙箱文件,得到绝对路径,再直接提交给 Core;宿主不创建 uploader,也不处理 短期媒体凭证:
typescript
const accepted = await XHIMMediaMessaging.sendImage(
client,
conversationId,
persistedAbsolutePath,
image!.mimeType,
width,
height
)视频、语音和文件分别使用 sendVideo、sendAudio、sendFile。返回值只 表示任务已持久化受理;收到 MEDIA_TASK_UPDATED 后按 taskId 调用 client.mediaTask() 查询终态。cancelRequest() 只取消一次 API 等待, cancelMediaTask() 才会持久化取消媒体任务。
收到消息里的稳定 XHIMMediaRef 后可创建私有缓存下载:
typescript
const download = await client.acceptMediaDownload({
cacheKey: util.generateRandomUUID(),
mediaRef: mediaRef
})cacheKey 是扁平标识而非路径;不得拼接 storagePath 猜测缓存位置。 client.diagnostics() 是同步、只读、纯内存健康快照,不包含账号、消息正文、 用户凭证、本地路径或签名 URL。
下载任务到达 COMPLETED 后,通过 Reader 消费已校验字节:
typescript
const reader = await client.openMediaCacheReader(cacheKey, mediaRef)
try {
await reader.stream(async (chunk: ArrayBuffer): Promise<boolean> => {
await decoder.append(chunk)
return true
})
} finally {
await reader.close()
}open、完整 SHA-256 校验和 read 均走 Node-API I/O worker;Promise chain 保证 同一 Reader 的 read/close 串行。consumer 返回 false 即停止读取,不调用 通用 cancelRequest。
系统网络回调报告恢复可用时调用 client.notifyNetworkAvailable()。这是同步 best-effort 提示,无 callback/request ID;Core 原有退避定时器继续保底。
5.1 位置、名片、回复、合并转发与本地搜索
typescript
import {
XHIMMessage,
XHIMStandardMessageFactory
} from '@xihansoftware/xhim'
const location = XHIMStandardMessageFactory.location(
31.2304,
121.4737,
'人民广场',
'上海市黄浦区'
)
await client.sendMessage(conversationId, location)
const card = XHIMStandardMessageFactory.contactCard('bob', 'Bob')
await client.sendMessage(conversationId, card)
const result = await client.searchMessages({
text: '合同',
conversationId: conversationId,
contentTypes: ['text/plain', 'xhim.media.file']
})
result.messages.forEach((message: XHIMMessage) => {
console.info(message.fallbackText)
})搜索只访问当前账号本地已经同步的安全文本投影。下一页必须原样传回 result.nextCursor,不要解析或改写它。
5.2 自定义消息
typescript
import { XHIMOutgoingMessage } from '@xihansoftware/xhim'
import { util } from '@kit.ArkTS'
const custom: XHIMOutgoingMessage = {
contentType: 'com.customer.message.order-card',
contentVersion: 1,
payload: new util.TextEncoder().encodeInto(
'{"orderId":"order-1001","title":"待付款订单"}'
).buffer,
fallbackText: '[订单] 待付款订单'
}
await client.sendMessage(conversationId, custom)接收端按 contentType + contentVersion 解码,未知版本展示 fallbackText。 需要集中验证、会话预览和 ArkUI Renderer 时注册 XHIMMessagePluginRegistry;插件失败必须回退,不能阻塞时间线。
6. 登出与常见问题
typescript
await client.disablePushDevice(pushInstallationId)
await client.logout()
await client.shutdown()
unsubscribe()
projectionRefresh.stop()Cannot load libxhim_napi.so:确认 HAR 含当前设备 ABI 且已进入最终 HAP;DEVELOPMENT_LOGIN_DISABLED:当前地址不允许 Easy Login,改用 Business Provider;- 状态未到
READY:检查网络权限、Bootstrap HTTPS、系统时间和业务会话; - 能发送但页面不刷新:订阅事件后重新调用
messages,不要缓存旧 Page; - Picker 成功但媒体失败:先把临时 URI 复制到 App 私有持久目录。
连接错误按 XHIMConnectionError.code 分支;运行时按 XHIMNativeError.code/domain/stableCode/retryable 分支。不要解析可读 message 决定重试,也不要记录凭证、完整 Payload 或附件路径。
7. 双端与 Production 发布验收
- 服务端预先创建测试 User ID
alice、bob,并把两个账号加入同一测试会话; - 两台设备分别只传入 Server URL 和自己的测试 User ID 调用
XHIMClient.connect(...),由 Development Easy Connect 自动完成公开配置、 测试登录和登录状态维护; - 两端互发文字、图片、视频和文件,并验证已读、编辑、撤回以及离线恢复;
- 真机检查
arm64-v8a、前后台、锁屏、网络切换、Easy Connect 自动续凭和 进程恢复; - Production 使用业务鉴权回调,禁用 Local Preview 和 Easy Login;
- 验证会话/历史分页、自定义消息、社交治理、请求取消和失败幂等重试;
- 校验 HAP/HAR 签名、OHPM 完整性、native
.so、符号、LICENSE、NOTICE、SBOM; - 不把固定登录票据、临时媒体 URL 或数据库 Key 写入 ArkTS/HAR。
Node-API、C++ runtime、请求取消和发布细节见 HarmonyOS 完整指南。
附录 A:好友、群组和黑名单写入
所有方法最后一个参数都可接收 onRequest(requestId),因此十一种写入都能通过 client.cancelRequest(requestId) 取消等待:
typescript
const request = await client.sendFriendRequest(
'bob', util.generateRandomUUID(), '我是 Alice')
await client.resolveFriendRequest(
request.requestId,
XHIMFriendRequestDecision.ACCEPT,
util.generateRandomUUID())
await client.deleteFriendship('bob', util.generateRandomUUID())
const group = await client.createGroup(
'项目群', ['alice', 'bob'], util.generateRandomUUID())
const roster = await client.changeGroupMembers(
group.group.conversationId,
['carol'],
[],
group.group.revision,
util.generateRandomUUID())
await client.setBlock('spam-user', true, util.generateRandomUUID())
const join = await client.requestGroupJoin(
roster.group.conversationId, util.generateRandomUUID())
await client.resolveGroupJoin(
join.request.requestId,
XHIMGroupJoinDecision.ACCEPT,
util.generateRandomUUID())
await client.changeGroupGovernance(
roster.group.conversationId,
roster.group.revision,
util.generateRandomUUID(),
{
action: XHIMGroupGovernanceAction.SET_JOIN_APPROVAL_REQUIRED,
required: true
})
const left = await client.leaveGroup(
memberGroup.conversationId,
memberGroup.revision,
util.generateRandomUUID())
const dismissed = await client.dismissGroup(
ownedGroup.conversationId,
ownedGroup.revision,
util.generateRandomUUID())mutationId 每个逻辑写入只生成一次;原请求重试复用,不同参数复用会得到 IDEMPOTENCY_CONFLICT。expectedRevision 是最新 group.revision 的精确 CAS,冲突后先重新查询。取消 requestId 不会回滚已经提交的事务。错误分支仅看 XHIMNativeError.code/domain/stableCode,不得解析 message 或记录用户凭证、 申请附言和资料内容。 退出和解散结果的 groupChange 是权威服务端投影,idempotentReplay 表示同一 逻辑写入的既有结果被重放。示例中的 memberGroup、ownedGroup 分别来自最新 成员群和本人拥有群查询。
附录 B:离线只读数据库
使用 XHIMOfflineReader 读取已有账号数据库。N-API 使用 async work,ArkTS 调用方不需要创建 taskpool:
typescript
const reader: XHIMOfflineReader = await XHIMOfflineReader.open(
{
databasePath: accountDatabasePath,
accountId: currentUserId
},
async (): Promise<ArrayBuffer> => {
// 从 HUKS 保护的应用安全存储解封随机数据库 Key。
return secureDatabaseKeys.unwrap(currentUserId)
}
)
try {
const first: XHIMMessagePage = await reader.messages(conversationId)
if (first.nextCursor !== undefined) {
await reader.messages(conversationId, first.nextCursor)
}
await reader.searchMessages({ text: '合同' })
await reader.conversations()
await reader.friendRequests()
await reader.friendships()
await reader.groups()
await reader.groupMembers(groupId)
await reader.blocks()
await reader.groupJoinRequests()
} finally {
await reader.close()
}数据库 Key 不得写入 ArkTS、resources、Preferences 或 HAR。SDK 清理自己的 临时副本;N-API 在后台深拷贝所有 borrowed view,ArkTS 通过单条 Promise 链 串行句柄操作。nextCursor 是不透明 ArrayBuffer,只能原样传回。 close() 异步且幂等,关闭后的调用会被 facade 拒绝。
附录 C:登录设备管理
typescript
let requestId: bigint = BigInt(0)
const page: XHIMDeviceSessionPage = await client.listDeviceSessions(
(value: bigint): void => { requestId = value }
)
const target: XHIMDeviceSession | undefined = page.sessions.find(
(session: XHIMDeviceSession) => !session.current && session.active
)
if (target !== undefined) {
const result: XHIMDeviceSessionRevocation =
await client.revokeDeviceSession(
target.sessionId,
util.generateRandomUUID(),
(value: bigint): void => { requestId = value }
)
// changed=false 表示此前已过期/失活,仍是成功幂等结果。
}
// 同步只读、调用方持有;不依赖先调用列表,也不执行网络或磁盘 I/O。
const policy: XHIMDeviceSessionPolicy =
client.deviceSessionPolicySnapshot()需要取消等待时调用 client.cancelRequest(requestId)。精确重试复用同一个 mutationId;新操作生成新值。自撤销返回 current=true、active=false, 应用应回到登录态。登录完成前或登出后读取策略会抛出 XHIMNativeError(code=2), 即原生 INVALID_STATE;不要用空策略推断登录态。toString() 与业务日志都 不得输出会话 ID、设备 ID 或撤销原因。
附录 D:协议兼容快照(仅诊断/灰度)
typescript
const compatibility: XHIMCompatibilitySnapshot =
client.compatibilitySnapshot()
hilog.info(
0x0000,
'XHIM',
'protocol %{public}d/%{public}d',
compatibility.clientProtocolVersion,
compatibility.serverProtocolVersion
)普通业务通常无需处理协议协商,登录已经对不兼容服务端 fail-closed。该同步 getter 只读取登录时严格验证并深拷贝的内存快照,不进行网络或磁盘 I/O,仅用于 诊断、支持包和受控灰度观测。登录前或登出后会保留 XHIMNativeError(code=2)(原生 INVALID_STATE)。不要用 capability 绕过 登录结果,也不要记录 SDK/Server 版本或 capability 值;toString() 只输出 协议号和数量。