场景教程:IMKit 自定义会话列表与主题
会话列表是 IM 应用的第一屏:排序、未读角标、免打扰、草稿、@ 提醒都要对,同时要跟随系统深浅色。本教程用三端真实组件 API 实现“自定义会话列表 + 主题令牌”,主题令牌取自同一份契约(9 个颜色令牌),因此三端换肤后的视觉一致。
IMKit 只负责渲染与交互,会话/消息数据始终来自 IMLib 公开 API(getConversationList 与 CONVERSATION、MESSAGES 事件)。
IMKit 不发起连接、不持有业务状态;主题只影响渲染,不改变数据。
三端令牌同名同义:primary、background、surface、text、textSecondary、bubbleMine、bubbleOther、danger、mention。
主题令牌契约
| 令牌 | 用途 | 浅色默认(Web) | 深色默认(Web) |
|---|---|---|---|
| primary | 主色:发送按钮、选中态、链接 | #4f46e5 | #818cf8 |
| background | 页面背景(会话列表底色) | #f5f6fa | #0f172a |
| surface | 卡片/列表行背景 | #ffffff | #1e293b |
| text | 主文本 | #1f2430 | #e2e8f0 |
| textSecondary | 次要文本(时间、摘要) | #7a8194 | #94a3b8 |
| bubbleMine | 自己气泡背景 | #4f46e5 | #4f46e5 |
| bubbleOther | 对方气泡背景 | #ffffff | #1e293b |
| danger | 危险/错误(撤回、失败态) | #dc2626 | #f87171 |
| mention | @ 提及高亮 | #4f46e5 | #a5b4fc |
深色不是简单反色:文字与背景对比度、气泡在深色背景上的边界都需要单独取值。Android 的令牌来自主题资源(同名 token 由 IMKitTheme.color(token) 解析,支持宿主覆盖);Flutter 的 ImkitTheme.light / ImkitTheme.dark 提供同名默认值。
Web:Theme + ConversationListView
Theme 支持 Theme.light(colors)、Theme.dark(colors)、Theme.default()(跟随系统)与 withColors(partial)(不可变覆盖);apply(el) 把令牌写成 CSS 变量(--imkit-*),toCSSVars() 返回变量表供宿主样式复用。会话列表 ConversationListView 接收 conversations、activeTargetId、options 与 theme,行节点带稳定的 class 与 data 属性,宿主 CSS 可以覆盖而不改组件。
import { ConversationListView, Theme, messagePreview, formatListTime } from '@ni-paas/im-web/imkit';
import { IMKitFacade } from '@ni-paas/im-web/imkit';
// 1) 主题:跟随系统 + 品牌色覆盖;切换时只重建 Theme 并 apply。
const media = matchMedia("(prefers-color-scheme: dark)");
let theme = media.matches ? Theme.dark() : Theme.light();
theme = theme.withColors({ primary: "#0ea5e9", mention: "#0ea5e9" });
// 2) 门面挂载:同一容器重复 mount 会复用实例并 update,不会重复创建。
const kit = new IMKitFacade({ container: "#conversation-pane", theme });
const list = kit.mount("ConversationListView", {
conversations: [], // 来自 IMLib.getConversationList()
activeTargetId: "",
options: { showPinned: true, showUnread: true, showDraft: true, showMention: true },
virtualThreshold: 100, // 超过 100 条启用窗口化渲染
onSelect: ({ conversation }) => openChat(conversation),
onLongPress: ({ conversation }) => showActions(conversation),
});
media.addEventListener("change", (event) => {
theme = (event.matches ? Theme.dark() : Theme.light()).withColors({ primary: "#0ea5e9", mention: "#0ea5e9" });
kit.setup({ theme });
list.update({ conversations: list.props.conversations, theme }); // 只重绘,不重建
});
// 3) 自定义行内容:优先复用官方 helper,保证与其它端的摘要一致。
const preview = messagePreview(conversation.lastMessage); // "[图片]"、"[草稿]" 等
const time = formatListTime(conversation.updatedAt); // 今天 HH:mm / 昨天 / MM-dd行节点的 class 是稳定契约:.conv-item、.conv-main、.conv-top、.conv-title、.conv-time、.conv-preview、.mention-badge、.pin-mark、.mute-mark;行上还有 data-key、data-conversation-type、data-target-id,宿主 CSS/埋点可据此定位。
/* 宿主 CSS 优先:IMKit 兜底样式用 :where() 零特异性,覆盖不需要 !important。 */
.conv-item { border-radius: 12px; margin: 4px 8px; }
.conv-item.active { box-shadow: inset 3px 0 0 var(--imkit-primary); }
.conv-title { font-weight: 700; }
.conv-preview { color: var(--imkit-text-secondary); }
.mention-badge { background: var(--imkit-mention); color: #fff; border-radius: 999px; }会话数超过 virtualThreshold(默认 100)时,列表只保留“可视窗口 + overscan”行;此时用 querySelectorAll(".conv-item") 只能拿到窗口内的行,不能用来统计全部会话。
需要全量数据时读组件属性(conversations)或 IMLib 的 getConversationList(),不要依赖 DOM 行数。
窗口化时行高由实测决定,宿主改行高(padding/字体)后组件会重新测量;如需固定行高,请一并调整 --imkit-conversation-item-height 对应的样式。
Android:IMKitTheme + ConversationListFragment
Android 通过 IMKit.init(context, IMKitOptions) 注入主题:themeMode 支持 IMKitTheme.Mode.LIGHT/DARK/SYSTEM,themeColors 按令牌名覆盖(ARGB int 或 #RRGGBB 字符串)。会话列表用 ConversationListFragment(或内置 ConversationListActivity),点击/长按通过 ConversationListBehaviorListener 回调给宿主。
// 1) 初始化:主题令牌覆盖 + 跟随系统深浅色。
IMKit.init(context, new IMKitOptions.Builder()
.credentials("demo", "http://127.0.0.1:8080")
.themeMode(IMKitTheme.Mode.SYSTEM)
.themeColors(new HashMap<String, Object>() {{
put("primary", "#0ea5e9");
put("mention", "#0ea5e9");
}})
.build());
// 2) 会话列表行为回调:返回 true 表示宿主已处理,不再走默认跳转。
IMKit.setConversationListBehaviorListener(new ConversationListBehaviorListener() {
@Override public boolean onConversationClick(Context context, View view, Conversation conversation) {
IMKit.routeToConversation(context, new ConversationIdentifier(
conversation.getConversationType(), conversation.getTargetId()));
return true;
}
@Override public boolean onConversationLongClick(Context context, View view, Conversation conversation) {
showConversationActions(conversation); // 自定义菜单:置顶/免打扰/删除
return true;
}
});
// 3) 打开会话列表;也可用 Fragment 自行挂载(newInstance() + FragmentManager)。
IMKit.routeToConversationList(context);
// 4) 运行时读取令牌(自定义 View 与 IMKit 保持一致)。
int primary = IMKit.theme().color("primary");
int rowHeight = IMKit.theme().dimen("conversationItemHeight");
boolean dark = IMKit.theme().isDark();令牌覆盖必须在创建 View 之前生效;IMKit.init 会调用 IMKitTheme.load,重复 init 只更新主题与配置并复用同一个 IMLib 客户端,因此“设置页切换主题”可以直接重新 init 或重建界面。会话项布局与颜色资源可通过宿主主题覆盖,未覆盖时使用 IMKit 默认资源。
Flutter:ImkitTheme + ConversationListView
Flutter 的 ImkitTheme 是不可变值对象(light / dark 默认值 + copyWith 覆盖 + toThemeData() 生成 Material 主题 + toJson() 便于持久化);ImkitThemeScope 可把主题注入子树,ImkitTheme.of(context) 跟随系统亮度。会话列表 ConversationListView 接受 conversations/controller、activeTargetId、options、theme、onSelect、onLongPress、onRefresh。
import 'package:flutter/material.dart';
import 'package:impaas_imkit/impaas_imkit.dart';
final theme = ImkitTheme.light.copyWith(
primary: const Color(0xFF0EA5E9),
mention: const Color(0xFF0EA5E9),
);
// 1) 注入主题(子树内所有 IMKit 组件生效)。
MaterialApp(
theme: theme.toThemeData(),
home: ImkitThemeScope(
theme: theme,
child: Builder(
builder: (context) {
final controller = ConversationListController(client: client)..load();
return ConversationListView(
controller: controller, // 由 controller 负责加载/刷新/排序
activeTargetId: activeTargetId,
options: const ConversationListOptions(showDraft: true, showMention: true),
onSelect: (conversation) => openChat(conversation),
onLongPress: (conversation) => showActions(conversation),
onRefresh: () => controller.refresh(),
);
},
),
),
);
// 2) 本地操作:置顶/已读/删除都走 controller,列表自动刷新。
await controller.toggleTop(conversation);
await controller.markRead(conversation);
await controller.remove(conversation);深色切换:ImkitThemeScope 的 theme 变化会触发子树重建;如果希望跟随系统,传 ImkitTheme.of(context) 的解析结果或直接用默认 theme: null。ImkitTheme.toJson() 与 copyWith 适合把用户选择持久化后下次启动恢复。
三端一致性验收清单
| 检查项 | 期望行为 | Web | Android | Flutter |
|---|---|---|---|---|
| 排序 | 置顶优先,其余按 updatedAt 倒序 | ✅ 组件内排序 | ✅ Fragment 内排序 | ✅ sortConversations |
| 未读角标 | >99 显示 99+;免打扰不显示红点(保留数字可选) | ✅ UnreadBadge | ✅ UnreadBadgeView | ✅ UnreadBadge |
| @ 提醒 | 独立角标,优先于普通未读 | ✅ mention-badge | ✅ 独立角标 | ✅ mentionedCount |
| 草稿 | 显示 [草稿] 前缀,切换会话保留 | ✅ options.showDraft | ✅ options.showDraft | ✅ hasDraft/draft |
| 空态/骨架 | 无数据显示空态;加载显示骨架 | ✅ | ✅ | ✅ |
| 深浅色 | 令牌切换后文字/背景对比度可读 | ✅ Theme.apply | ✅ IMKitTheme.Mode | ✅ ImkitTheme |
| 长按/右键 | 触发 longpress 交给宿主 | ✅ contextmenu + 600ms 长按 | ✅ onConversationLongClick | ✅ onLongPress |
错误处理
| 场景 | 表现 | 处理 |
|---|---|---|
| 容器不存在 / 选择器写错 | Web mount 抛错并触发 error 事件 | 检查容器是否已挂载;用 try/catch 包裹 mount,失败时降级为纯 DOM 列表 |
| 重复 mount 同一容器 | 复用实例并 update(不报错) | 预期行为;切换会话/主题时用 update 而不是 destroy + mount |
| 未知组件名 | mount 抛 “未知组件” 错误 | 组件名限定为 ConversationListView/ChatView/MessageBubble/MessageInput/Avatar/UnreadBadge/TimeSeparator/MessageStatusIndicator |
| IMKit 未初始化(Android) | IllegalStateException | 在 Application.onCreate 或首屏前调用 IMKit.init;无凭据时用 IMKitOptions.client 注入已初始化的 IMLib |
| 会话列表为空 | 显示空态 | 确认已连接且 getConversationList 返回非空;未连接时先等 CONNECTED |
| 主题切换后局部不生效 | 仅部分元素变色 | 检查是否自己写了内联颜色:改用令牌/CSS 变量,避免硬编码 |
限制
- IMKit 不提供消息发送、连接与本地库能力:这些由 IMLib 负责,IMKit 只消费其公开数据。
- 主题令牌只有 9 个公开颜色;更细的视觉(间距、圆角、字号)通过宿主 CSS / XML 样式 / Flutter 组件包装覆盖,不属于令牌契约。
- Web 的窗口化渲染会复用/回收行节点,宿主不要在行上保存长期状态;用 data-key 做映射。
- Android 的 Activity 可替换(IMKit.registerActivity),但替换后需自行保证与 IMKit 的生命周期约定一致。
- Flutter 的会话列表不提供自定义行 builder;需要完全自定义行时,用 ConversationListController 的数据自行构建 ListView。
- 会话列表数据来自 IMLib,IMKit 不做本地缓存;冷启动需要有连接或 REST 拉取。