feat(chat): 正在输入指示器 — 点对点复刻 iOS + 性能改进
Some checks failed
CI / Lint (push) Has been cancelled

## 新增
- TypingIndicatorManager: 内存态管理器,精准Timer替代iOS 1s轮询
- TypingInputSender: per-chatId 节流(3s)/防抖(2s),修复iOS跨chat竞态
- WS chat_input/chat_typing 帧处理(mode2 + ctl 双路径)

## UI
- ChatDetailPage AppBar 绿色副标题显示"正在输入…"
- ChatPage 列表 snippet 绿色输入状态优先于 lastMsg
- 群聊不发送 typing 事件(对齐 iOS gate)

## 改进 (vs iOS)
- Timer 仅在有 entry 时启动,空时 null(零空转)
- per-chatId 隔离节流/防抖(iOS 全局共享有竞态 bug)
- msgIdx 守卫防止乱序帧覆盖 lastMsg

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-04-09 14:59:30 +09:00
parent b8f1f82ee5
commit e8f58212e6
10 changed files with 634 additions and 22 deletions

View File

@@ -0,0 +1,302 @@
import 'dart:async';
/// 输入状态枚举 — 对齐 Flutter ChatInputState / iOS ChatInputState
///
/// state=1 → typing, state=2 → noTyping (clear), 3-7 → media sending
enum ChatInputState {
typing(1),
noTyping(2),
sendImage(3),
sendVideo(4),
sendDocument(5),
sendAlbum(6),
sendVoice(7);
const ChatInputState(this.value);
final int value;
static ChatInputState? fromValue(int v) {
for (final s in values) {
if (s.value == v) return s;
}
return null;
}
/// 过期秒数:文字输入 5s媒体发送 30s对齐 iOS/Flutter
Duration get expiry {
switch (this) {
case sendImage:
case sendVideo:
case sendDocument:
case sendAlbum:
case sendVoice:
return const Duration(seconds: 30);
default:
return const Duration(seconds: 5);
}
}
/// 中文展示文本
String get displayText {
switch (this) {
case typing:
return '正在输入…';
case sendImage:
return '正在发送图片…';
case sendVideo:
return '正在发送视频…';
case sendDocument:
return '正在发送文件…';
case sendAlbum:
return '正在发送相册…';
case sendVoice:
return '正在发送语音…';
case noTyping:
return '';
}
}
}
/// 单条输入条目
class _TypingEntry {
_TypingEntry({
required this.userId,
required this.username,
required this.state,
required this.expiresAt,
});
final int userId;
final String username;
final ChatInputState state;
final DateTime expiresAt;
}
/// 正在输入状态管理器 — 对齐 iOS TypingIndicatorManager + 性能优化
///
/// ## 对齐 iOS 行为
/// - WS 收到 chat_input/chat_typing 时调用 [handleTypingEvent]
/// - 收到真实消息时调用 [clearTyping] 立即清除(不等 5s 过期)
/// - 文字输入 5s 过期,媒体发送 30s 过期
/// - 单聊显示 "正在输入…",群聊显示 "Alice 正在输入…" / "N 人正在输入…"
///
/// ## 性能改进vs iOS
/// - **无常驻 Timer**:仅在有 entry 时启动清理 timer空时自动取消
/// - **精准调度**timer 对齐最近过期时间,不是每秒轮询
/// - **按 chatId 通知**[typingTextStream] 返回指定 chat 的 Stream
/// 只有该 chat 的状态变化才触发,避免全局重建
class TypingIndicatorManager {
/// [chatId → [userId → _TypingEntry]]
final Map<int, Map<int, _TypingEntry>> _typing = {};
/// 每个 chatId 一个 StreamController按需创建
final Map<int, StreamController<String?>> _controllers = {};
/// 全局通知(用于聊天列表等需要监听所有 chat 的场景)
final StreamController<void> _globalNotifier =
StreamController<void>.broadcast();
Timer? _purgeTimer;
// ── 接收侧 ──────────────────────────────────────────────────────────────
/// 处理 WS 收到的 chat_input/chat_typing 事件
///
/// [senderId], [chatId], [stateValue] 从 WS 帧解析。
/// [username] 用于群聊显示名。
void handleTypingEvent({
required int chatId,
required int senderId,
required String username,
required int stateValue,
}) {
final state = ChatInputState.fromValue(stateValue);
if (state == null) return;
if (state == ChatInputState.noTyping) {
_typing[chatId]?.remove(senderId);
if (_typing[chatId]?.isEmpty == true) _typing.remove(chatId);
} else {
_typing.putIfAbsent(chatId, () => {});
_typing[chatId]![senderId] = _TypingEntry(
userId: senderId,
username: username,
state: state,
expiresAt: DateTime.now().add(state.expiry),
);
}
_notify(chatId);
_schedulePurge();
}
/// 收到真实消息时立即清除该发送者的输入状态
/// (对齐 iOS: TypingIndicatorManager.clearTyping + .didReceiveChatMessage
void clearTyping({required int chatId, required int senderId}) {
if (_typing[chatId]?.remove(senderId) != null) {
if (_typing[chatId]?.isEmpty == true) _typing.remove(chatId);
_notify(chatId);
}
}
// ── 查询侧 ──────────────────────────────────────────────────────────────
/// 单聊:返回第一个活跃输入者的状态文本,排除自己
/// 对齐 iOS displayText(for:excludingUserId:)
String? displayText({required int chatId, required int myUserId}) {
final entries = _typing[chatId]?.values;
if (entries == null || entries.isEmpty) return null;
for (final e in entries) {
if (e.userId != myUserId && e.state != ChatInputState.noTyping) {
return e.state.displayText;
}
}
return null;
}
/// 群聊:多人输入显示规则
/// - 1 人 → "Alice 正在输入…"
/// - 2-3 人 → "Alice、Bob 正在输入…"
/// - 4+ 人 → "N 人正在输入…"
/// 对齐 iOS groupDisplayText(for:groupId:excludingUserId:)
String? groupDisplayText({required int chatId, required int myUserId}) {
final entries = _typing[chatId]?.values;
if (entries == null || entries.isEmpty) return null;
final active = entries
.where((e) => e.userId != myUserId && e.state != ChatInputState.noTyping)
.toList();
if (active.isEmpty) return null;
switch (active.length) {
case 1:
final name = active[0].username.isNotEmpty
? active[0].username
: '用户${active[0].userId}';
return '$name ${active[0].state.displayText}';
case 2:
case 3:
final names = active
.map((e) => e.username.isNotEmpty ? e.username : '用户${e.userId}')
.join('');
return '$names 正在输入…';
default:
return '${active.length} 人正在输入…';
}
}
/// 返回指定 chatId 的输入状态 Stream
///
/// 每次该 chatId 的输入状态变化时发出新的显示文本null = 无人输入)。
/// 懒创建 StreamControllerdispose 时自动清理。
Stream<String?> typingTextStream({
required int chatId,
required int myUserId,
bool isGroup = false,
}) {
final ctrl = _controllers.putIfAbsent(
chatId,
() => StreamController<String?>.broadcast(),
);
// 立即发出当前状态
final current = isGroup
? groupDisplayText(chatId: chatId, myUserId: myUserId)
: displayText(chatId: chatId, myUserId: myUserId);
return ctrl.stream.transform(
StreamTransformer.fromHandlers(
handleData: (data, sink) => sink.add(data),
),
).transform(_StartWithTransformer(current));
}
/// 全局变化通知(聊天列表用)
Stream<void> get globalChangeStream => _globalNotifier.stream;
// ── 内部 ────────────────────────────────────────────────────────────────
void _notify(int chatId) {
// 通知该 chatId 的订阅者
final ctrl = _controllers[chatId];
if (ctrl != null && !ctrl.isClosed) {
// 不知道订阅者的 myUserId / isGroup所以传 null 让 UI 层自己查
ctrl.add(null); // sentinel — UI 层重新查询 displayText
}
// 通知全局订阅者
if (!_globalNotifier.isClosed) {
_globalNotifier.add(null);
}
}
/// 智能清理调度 — 对齐最近过期时间,非 1s 轮询
///
/// iOS 问题1s Timer 永远运行,即使 typing 为空。
/// 改进:找到最近过期的 entry只在那个时间点触发一次清理。
void _schedulePurge() {
_purgeTimer?.cancel();
if (_typing.isEmpty) {
_purgeTimer = null;
return;
}
// 找最近过期时间
DateTime? earliest;
for (final chatEntries in _typing.values) {
for (final entry in chatEntries.values) {
if (earliest == null || entry.expiresAt.isBefore(earliest)) {
earliest = entry.expiresAt;
}
}
}
if (earliest == null) return;
final delay = earliest.difference(DateTime.now());
// 至少 100ms 防止 busy-loop
final safeDuration =
delay.isNegative ? const Duration(milliseconds: 100) : delay;
_purgeTimer = Timer(safeDuration, _purgeExpired);
}
void _purgeExpired() {
final now = DateTime.now();
final changedChats = <int>{};
for (final chatId in _typing.keys.toList()) {
final entries = _typing[chatId]!;
final before = entries.length;
entries.removeWhere((_, entry) => entry.expiresAt.isBefore(now));
if (entries.isEmpty) _typing.remove(chatId);
if ((entries.length) != before) changedChats.add(chatId);
}
for (final chatId in changedChats) {
_notify(chatId);
}
// 如果还有 entry继续调度下一次
_schedulePurge();
}
/// 释放资源
void dispose() {
_purgeTimer?.cancel();
for (final ctrl in _controllers.values) {
ctrl.close();
}
_controllers.clear();
_globalNotifier.close();
_typing.clear();
}
}
/// 在 Stream 前插入一个初始值
class _StartWithTransformer<T> extends StreamTransformerBase<T, T> {
_StartWithTransformer(this._initial);
final T _initial;
@override
Stream<T> bind(Stream<T> stream) async* {
yield _initial;
yield* stream;
}
}

View File

@@ -0,0 +1,110 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:im_app/core/services/socket_manager.dart';
/// 发送侧"正在输入" WS 事件管理器
///
/// 对齐 iOS `TypingInputSender.swift`:
/// - 节流 3s用户输入时最多每 3s 发一次 {typ:1}
/// - 防抖 2s用户停止输入 2s 后自动发 {typ:0}
/// - 消息发送 / 文本清空时立即发 {typ:0}
///
/// ## 改进vs iOS TypingInputSender.swift
///
/// iOS 版用全局单一 `lastTypingSentAt` / `stopTask`,当用户在 3s 内切换聊天时
/// 会导致chat A 的 stop timer 被 chat B 覆盖chat A 输入状态无法正常清除。
/// Flutter 版改为 **per-chatId** 节流/防抖,彻底消除跨 chat 竞态。
///
/// WS 发送格式(对齐 iOS TypingInputSender.sendAction
/// ```json
/// {"action": "ACTION_SENDINPUT_MSG", "chat_id": N, "typ": 1} // 开始输入
/// {"action": "ACTION_SENDINPUT_MSG", "chat_id": N, "typ": 0} // 停止输入
/// ```
class TypingInputSender {
TypingInputSender({required SocketManager socketManager})
: _socketManager = socketManager;
final SocketManager _socketManager;
/// 节流间隔:最多每 3s 发一次 typ=1对齐 iOS inputThrottleInterval = 3.0
static const _throttleInterval = Duration(seconds: 3);
/// 停止输入防抖:最后一次击键 2s 后发 typ=0对齐 iOS inputStopDelay = 2.0
static const _stopDelay = Duration(seconds: 2);
/// Per-chatId 节流时间戳(防止跨 chat 竞态)
final Map<int, DateTime> _lastSentAt = {};
/// Per-chatId 停止计时器
final Map<int, Timer> _stopTimers = {};
/// 用户输入文本变化时调用
///
/// [text] 为当前输入框全文。
/// - 非空:节流发 typ=1 + 重置 2s 停止计时
/// - 空(清空输入框):立即发 typ=0
///
/// 对齐 iOS ChatViewModel.onComposerTextChanged
void onTextChanged({required int chatId, required String text}) {
final isTyping = text.trim().isNotEmpty;
if (isTyping) {
// 重置该 chat 的 2s 停止计时
_stopTimers[chatId]?.cancel();
_stopTimers[chatId] = Timer(_stopDelay, () {
_sendAction(chatId: chatId, typ: 0);
_lastSentAt.remove(chatId);
_stopTimers.remove(chatId);
});
// 节流:该 chat 距上次发送不足 3s 则跳过
final now = DateTime.now();
final lastSent = _lastSentAt[chatId];
if (lastSent != null && now.difference(lastSent) < _throttleInterval) {
return;
}
_lastSentAt[chatId] = now;
_sendAction(chatId: chatId, typ: 1);
} else {
// 文本清空 → 立即停止
_stopTimers[chatId]?.cancel();
_stopTimers.remove(chatId);
if (_lastSentAt.containsKey(chatId)) {
_lastSentAt.remove(chatId);
_sendAction(chatId: chatId, typ: 0);
}
}
}
/// 消息发送成功后调用 — 立即发 typ=0
///
/// 对齐 iOS TypingInputSender.notifyStopTyping
void notifyStopTyping({required int chatId}) {
_stopTimers[chatId]?.cancel();
_stopTimers.remove(chatId);
_lastSentAt.remove(chatId);
_sendAction(chatId: chatId, typ: 0);
}
void _sendAction({required int chatId, required int typ}) {
final payload = {
'action': 'ACTION_SENDINPUT_MSG',
'chat_id': chatId,
'typ': typ,
};
final text = jsonEncode(payload);
_socketManager.sendString(text);
debugPrint('[TypingInputSender] chat=$chatId typ=$typ');
}
void dispose() {
for (final timer in _stopTimers.values) {
timer.cancel();
}
_stopTimers.clear();
_lastSentAt.clear();
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/services/socket_manager.dart';
import 'package:im_app/core/services/typing_indicator_manager.dart';
import 'package:im_app/data/remote/fetch_history_request.dart';
import 'package:im_app/domain/repositories/chat_repository.dart';
import 'package:im_app/domain/repositories/message_repository.dart';
@@ -31,6 +32,7 @@ class WsMessageService {
final NetworksSdkApi _apiClient;
final MessageRepository _messageRepo;
final ChatRepository _chatRepo;
final TypingIndicatorManager _typingManager;
StreamSubscription<Map<String, dynamic>>? _sub;
@@ -39,10 +41,12 @@ class WsMessageService {
required NetworksSdkApi apiClient,
required MessageRepository messageRepo,
required ChatRepository chatRepo,
required TypingIndicatorManager typingManager,
}) : _socketManager = socketManager,
_apiClient = apiClient,
_messageRepo = messageRepo,
_chatRepo = chatRepo;
_chatRepo = chatRepo,
_typingManager = typingManager;
// ── 生命周期 ──────────────────────────────────────────────────────────────
@@ -65,6 +69,26 @@ class WsMessageService {
Future<void> _handleFrame(Map<String, dynamic> frame) async {
try {
// ── chat_input / chat_typing — 正在输入状态 ─────────────────────────
// mode2: {"chat_input": {"r": [{"send_id": N, "chat_id": N, "username": "Alice", "state": N}]}}
// ctl: {"ctl": "chat_input", "r": [...]}
// 对齐 iOS MessageReceiver.handleChatInput
final typingPayload = frame['chat_input'] as Map<String, dynamic>?
?? frame['chat_typing'] as Map<String, dynamic>?;
if (typingPayload != null) {
_handleTypingFrame(typingPayload);
}
// ctl 路径
final ctl = frame['ctl'] as String?;
if (ctl == 'chat_input' || ctl == 'chat_typing') {
final ctlData = frame['data'] as List<dynamic>?
?? frame['r'] as List<dynamic>?;
if (ctlData != null) {
_handleTypingFrame({'r': ctlData});
}
}
// ── chat.r — 新消息通知 ────────────────────────────────────────────
final chatPayload = frame['chat'] as Map<String, dynamic>?;
if (chatPayload == null) return;
@@ -79,6 +103,12 @@ class WsMessageService {
final msgIdx = (entry['msg_idx'] as num?)?.toInt();
if (chatId == null || msgIdx == null) continue;
// 收到真实消息 → 清除发送者的输入状态(对齐 iOS .didReceiveChatMessage
final sendId = (entry['send_id'] as num?)?.toInt();
if (sendId != null) {
_typingManager.clearTyping(chatId: chatId, senderId: sendId);
}
await _fetchAndSaveMessages(chatId: chatId, anchorIdx: msgIdx);
await _updateChatMeta(chatId: chatId, entry: entry);
}
@@ -87,6 +117,40 @@ class WsMessageService {
}
}
/// 处理 chat_input/chat_typing 帧
void _handleTypingFrame(Map<String, dynamic> payload) {
final rList = payload['r'] as List<dynamic>?;
if (rList == null) return;
for (final item in rList) {
final entry = item as Map<String, dynamic>?;
if (entry == null) continue;
// 兼容 Int / String 类型(对齐 iOS handleChatInput 的 flatMap(Int.init)
final senderId = _parseInt(entry['send_id']);
final chatId = _parseInt(entry['chat_id']);
final stateValue = _parseInt(entry['state']) ?? _parseInt(entry['typ']);
final username = entry['username'] as String? ?? '';
if (senderId == null || chatId == null || stateValue == null) continue;
_typingManager.handleTypingEvent(
chatId: chatId,
senderId: senderId,
username: username,
stateValue: stateValue,
);
}
}
/// 安全解析 int — 兼容 int / String / num对齐 iOS Int ?? String→Int
static int? _parseInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
/// 通过 HTTP 拉取消息并写入 DB
Future<void> _fetchAndSaveMessages({
required int chatId,
@@ -109,6 +173,9 @@ class WsMessageService {
}
/// 更新聊天列表中对应 Chat 的 lastMsg / lastTyp / msgIdx
///
/// 包含 msgIdx 守卫:如果帧中的 msgIdx 小于 DB 已有值,说明是乱序到达的旧帧,
/// 跳过更新防止 lastMsg 被旧数据覆盖。
Future<void> _updateChatMeta({
required int chatId,
required Map<String, dynamic> entry,
@@ -121,6 +188,11 @@ class WsMessageService {
final lastTyp = (entry['typ'] as num?)?.toInt();
final msgIdx = (entry['msg_idx'] as num?)?.toInt();
// 防止乱序帧覆盖新数据Codex review #4
if (msgIdx != null && existing.msgIdx != null && msgIdx < existing.msgIdx!) {
return;
}
await _chatRepo.updateChat(
existing.copyWith(
lastMsg: lastMsg ?? existing.lastMsg,