Skip to content

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.connectsendText”。

先确认发行包

只使用 Release Manifest 对应的签名 HAR/OHPM 包。发行包必须包含 libxhim_napi.solibxhim_core_v1.so、资源、类型声明、checksum、LICENSE、 NOTICE 和 SBOM;只复制 Index.ets 不能连接真实 Server。

1. 新建工程并添加 HAR

  1. DevEco Studio 选择 Create Project → Empty Ability
  2. 使用 ArkTS、Stage 模型,最低 API 以发布清单为准(附件 Picker 目标 API 11+);
  3. xhim.har 放入应用模块的 libs/
  4. 在模块 oh-package.json5 添加:
json5
{
  "dependencies": {
    "@xihansoftware/xhim": "file:./libs/xhim.har"
  }
}

企业 OHPM 发布后改为固定版本:

json5
"@xihansoftware/xhim": "<version>"

执行 ohpm install,确认 HAR、libxhim_napi.solibxhim_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 来源
appIdoptions.appId,否则 Bootstrap 默认值
storagePathoptions.storageRootPath 下按 App ID + User ID 隔离
deployment.bootstrapUrlserver
Endpoint Key ID + Public KeyBootstrap 公开配置

connect 自动完成以下工作:

  1. 只用 GET /v1/sdk/config 获取公开 App ID 和 Endpoint 验签公钥;
  2. App ID + User ID 创建不可猜测的账号隔离数据库目录;
  3. 创建并启动 Core;
  4. Development 模式完成测试用户登录;
  5. 自动保持登录状态并处理底层续期。

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_CHANGEDTYPING_CHANGED,分别读取 presenceUpdatetypingUpdate。只在开始/停止输入时上报,不要每次按键 调用;页面必须按 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
)

视频、语音和文件分别使用 sendVideosendAudiosendFile。返回值只 表示任务已持久化受理;收到 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 alicebob,并把两个账号加入同一测试会话;
  • 两台设备分别只传入 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_CONFLICTexpectedRevision 是最新 group.revision 的精确 CAS,冲突后先重新查询。取消 requestId 不会回滚已经提交的事务。错误分支仅看 XHIMNativeError.code/domain/stableCode,不得解析 message 或记录用户凭证、 申请附言和资料内容。 退出和解散结果的 groupChange 是权威服务端投影,idempotentReplay 表示同一 逻辑写入的既有结果被重放。示例中的 memberGroupownedGroup 分别来自最新 成员群和本人拥有群查询。

附录 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=trueactive=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() 只输出 协议号和数量。

XHIM 客户端 SDK 与服务端文档