场景教程: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 可以覆盖而不改组件。

JavaScript
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/埋点可据此定位。

Text
/* 宿主 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 回调给宿主。

Java
// 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。

Dart
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 适合把用户选择持久化后下次启动恢复。

三端一致性验收清单​

检查项期望行为WebAndroidFlutter
排序置顶优先,其余按 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 拉取。

相关链接​