Flutter - IM保持消息位置大升级(支持ChatGPT生成式消息) 🤖

2,774 阅读7分钟

欢迎关注微信公众号:FSA全栈行动 👋

系列文章

开源库: flutter_scrollview_observer

  1. Flutter - 获取ListView当前正在显示的Widget信息
  2. Flutter - 列表滚动定位超强辅助库,墙裂推荐!🔥
  3. Flutter - 快速实现聊天会话列表的效果,完美💯
  4. Flutter - 船新升级😱支持观察第三方构建的滚动视图💪
  5. Flutter - 瀑布流交替播放视频 🎞
  6. Flutter - IM保持消息位置大升级(支持ChatGPT生成式消息) 🤖
  7. Flutter - 滚动视图中的表单防遮挡 🗒
  8. Flutter - 秒杀1/2曝光统计 📊
  9. Flutter - 如何快速搓一个微信通讯录列表(azlist) 📓
  10. Flutter - 支持观察NestedScrollView,兼容性更强 😈

一、概述

【Flutter - 快速实现聊天会话列表的效果,完美💯】 一文中介绍了常规聊天场景下如何保持消息位置及其实现原理,本篇将在此基础上完成 ChatGPT 这种生成式消息保持位置的功能。

如上图所示,操作与现象:

  1. 点击右上角的按钮后,会插入一条消息,模拟向 ChatGPT 发出问题
  2. 然后间隔 1s 会自动插入一条生成式消息,模拟 ChatGPT 在不断回复我们的问题

二、布局

布局非常简单,就一个 ListView,不过需要结合 ChatObserver 做一些配置

1、ChatObserver 配置

// 实例化 ListObserverController
// 由于滚动视图使用的是 ListView,所以选择 ListObserverController
// 否则请选择相应的 ObserverController
observerController = ListObserverController(controller: scrollController)
  // 关闭模块定位的偏移缓存功能,因为IM经常添加或删除消息,偏移的缓存容易过时
  // 如果你不会使用到模块定位功能,则可以不用理会
  ..cacheJumpIndexOffset = false;

// 实例化 ChatScrollObserver
chatObserver = ChatScrollObserver(observerController)
  // 滚动视图的偏移量超过 5 时才会启用保持IM消息位置的功能
  ..fixedPositionOffset = 5
  // 内部在切换 isShrinkWrap 的值时会触发该回调,
  // 触发时局部刷新列表视图即可,这里因为是 Demo 的缘故,做法比较简单粗暴
  ..toRebuildScrollViewCallback = () {
    setState(() {});
  };

2、ListView 配置

Widget _buildListView() {
  Widget resultWidget = ListView.builder(
    // 内含处理保持IM消息位置的核心逻辑
    physics: ChatObserverClampingScrollPhysics(observer: chatObserver),
    padding: const EdgeInsets.only(left: 10, right: 10, top: 15, bottom: 15),
    // 切换滚动视图的高度模式,当消息不满一屏时,该值为 true,使消息在顶部展示,
    // 消息超一屏时为 false,消息在底部展示,chatObserver 内部会适时变更该值
    // 如果你希望一直都是底部展示,可以注释该行
    shrinkWrap: chatObserver.isShrinkWrap,
    // 消息从底部往上开始排序,所以来新消息时应该插入到 0 的位置
    reverse: true,
    controller: scrollController,
    itemBuilder: ((context, index) {
      return ChatItemWidget(...);
    }),
    itemCount: chatModels.length,
  );
  
  // 对滚动视图监听
  resultWidget = ListViewObserver(
    controller: observerController,
    child: resultWidget,
  );
  
  // 重点,如果你希望不满一屏时在顶部展示消息,而又不生效时,
  // 记得如下所示设置 alignment 为 Alignment.topCenter
  resultWidget = Align(
    child: resultWidget,
    alignment: Alignment.topCenter,
  );
  return resultWidget;
}

基本的配置到这就完成了,接下来看看如何为生成式消息保持位置吧

三、实战

AppBar 右边的按钮,点击时模拟发出一条问题消息,然后等待1s后 ChatGPT 以不断更新消息的方式回答问题

IconButton(
  onPressed: () async {
    // 关闭上一条更新生成式消息
    stopMsgUpdateStream();
    // 模拟发送一条问题消息
    _addMessage(isOwn: true);
    // 等待1s
    await Future.delayed(const Duration(seconds: 1));
    // 插入生成式消息
    insertGenerativeMsg();
  },
  icon: const Icon(Icons.add_comment),
)

停止旧生成式消息的更新,控制同一时间只允许存在一条生成式消息

stopMsgUpdateStream() {
  timer?.cancel();
  timer = null;
}

插入一条新消息方法

_addMessage({
  required bool isOwn,
}) {
  // 在符合条件的情况下保持当前的IM消息位置
  chatObserver.standby(changeCount: 1);
  setState(() {
    chatModels.insert(0, ChatDataHelper.createChatModel(isOwn: isOwn));
  });
}

插入生成式消息,模拟 ChatGPT 正在回答问题

insertGenerativeMsg() {
  // 停止之前的生成式消息
  stopMsgUpdateStream();
  // 插入一条
  _addMessage(isOwn: false);
  // 开始模拟接收更新的消息数据
  int count = 0;
  timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
    // 接收完毕
    if (count >= 60) {
      stopMsgUpdateStream();
      return;
    }
    count++;
    // 取出最新的生成式消息
    final model = chatModels.first;
    // 更新消息内容
    final newString = '${model.content}-1+1';
    // 替换消息
    final newModel = ChatModel(isOwn: model.isOwn, content: newString);
    chatModels[0] = newModel;
    // 准备保持位置
    chatObserver.standby(
      // 重点,使用生成式消息的处理模式
      mode: ChatScrollObserverHandleMode.generative,
      // changeCount: 1,
    );
    // 后面会解释该模式的使用
    // chatObserver.standby(
    //   changeCount: 1,
    //   mode: ChatScrollObserverHandleMode.specified,
    //   refItemRelativeIndex: 2,
    //   refItemRelativeIndexAfterUpdate: 2,
    // );
    // 刷新滚动视图,可局部刷新,Demo 缘故,做法比较简单粗暴
    setState(() {});
  });
}

四、用法解析

其实上面那一堆代码中,重点在于 standby 方法的使用

standby({
  // 滚动视图的 BuildContext
  // 当布局复杂使 scrollview_observer 无法顺利自动找到滚动视图的 BuildContext 时才需要传入
  // 如 CustomScrollView,或 ObserverWidget 包裹的 child 中有多个 ListView、GridView
  BuildContext? sliverContext,
  // 是否是删除消息的操作,它不会启用保持位置功能,而是可能切换 isShrinkWrap 的值
  bool isRemove = false,
  // 变更的IM消息数
  int changeCount = 1,
  // 处理模式
  ChatScrollObserverHandleMode mode = ChatScrollObserverHandleMode.normal,
  // 刷新滚动视图前,参照的相对item的下标
  int refItemRelativeIndex = 0,
  // 刷新滚动视图后,参照的相对item的下标
  int refItemRelativeIndexAfterUpdate = 0,
})

处理模式的定义如下

enum ChatScrollObserverHandleMode {
  /// 常规模式
  /// 插入或删除IM消息的时候使用
  normal,

  /// 生成式模式
  /// 专门处理 ChatGPT 这种不断更新消息的情况
  generative,

  /// 指定模式
  /// 在该模式下我们可以指定参照的IM消息的相对下标
  specified,
}

1、常规模式

常规模式为默认的处理模式,应用于日常的 IM 添加和删除消息的场景,比较简单

插入多条消息

_addMessage(int count) {
  // 进入待命状态,保持IM消息位置
  chatObserver.standby(changeCount: count);
  // 一次性插入多条消息,并刷新滚动视图
  setState(() {
    needIncrementUnreadMsgCount = true;
    for (var i = 0; i < count; i++) {
      chatModels.insert(0, ChatDataHelper.createChatModel());
    }
  });
}

删除消息

chatObserver.standby(isRemove: true);
setState(() {
  chatModels.removeAt(index);
});

2、生成式模式(仿ChatGPT)

专门处理 ChatGPT 这种生成式消息的场景

final model = chatModels.first;
final newString = '${model.content}-1+1';
final newModel = ChatModel(isOwn: model.isOwn, content: newString);
// 更新生成式消息的数据
chatModels[0] = newModel;
// 进入待命状态
chatObserver.standby(
  mode: ChatScrollObserverHandleMode.generative,
  // changeCount: 1,
);
// 刷新列表
setState(() {});
  • 指定处理模式 mode.generative
  • changeCount 默认值为 1,传与不传一样

内部会根据 changeCount 来计算并记录参照消息的相对下标(关于相对下标的内容会在 指定模式 一节中说明),这意味着该模式支持连续的多条生成式消息保持位置,比如最新的两条消息都是生成式的情况是支持。

而如果最新的三条消息,02 都是生成式消息而 1 不是,又或者同一时间插入消息和更新生成式消息,此时该模式是无法很好的支持保持消息位置这一功能的,那有什么办法呢?你可以使用 指定模式

3、指定模式

顾名思义,可以指定参照的 IM 消息的相对下标,从而可以自由的使用保持消息位置的功能,当然了,自由也意味着传入的数据和需要了解的会更多!

我们先来了解一下什么是参照的 IM 消息的相对下标?

注: 滚动视图内已渲染的 item 不一定会被显示出来,如果下文中提及的滚动视图渲染的 item 让你不好理解,你可以直接认为是屏幕中正在展示的 item

假如当前是正在看最新消息的情况,滚动视图内渲染了 item0item4,其相对下标为 04

     trailing        relativeIndex
-----------------  -----------------
|     item4     |          4
|     item3     |          3
|     item2     |          2
|     item1     |          1
|     item0     |          0 
-----------------  -----------------
     leading

如果此时你在翻看历史消息,滚动视图内渲染了 item10item14,其相对下标也为 04

     trailing        relativeIndex
-----------------  -----------------
|     item14    |          4
|     item13    |          3
|     item12    |          2
|     item11    |          1
|     item10    |          0 
-----------------  -----------------
     leading

这里 04 的下标即为相对下标,我们来使用该模式(.specified)来完成 .generative 模式的功能

在上述示例中,离发出问题间隔了1秒后,ChatGPT 会开始回答问题,此时我们插入生成式的消息,并不断更新该消息内容。

注意: 插入消息的方法内已经做了保持消息位置的处理,所以我们的关注点在于更新消息

     trailing        relativeIndex
-----------------  -----------------
|     item4     |          4
|     item3     |          3
|     item2     |          2
|     item1     |          1
|     item0     |          0 
-----------------  -----------------
     leading

假设此时 item0 为生成式消息,它的消息内容在不断变多,如果放任不管,item0 以上的消息会逐渐被往上顶,因此在这里我们的目的是不管 item0 如何变化,都需要保持 item1 及以上消息的位置,所以 item1 就成为了我们的参照消息,它此刻的下标为 1,而生成式消息的变化前后,item1 的下标一直都是 1

改造的代码如下:

chatObserver.standby(
  changeCount: 1,
  // 设置处理模式为 .specified
  mode: ChatScrollObserverHandleMode.specified,
  // 滚动视图更新前,参照消息的相对下标
  refItemRelativeIndex: 1,
  // 滚动视图更新后,参照消息的相对下标
  refItemRelativeIndexAfterUpdate: 1,
);

注意,refItemRelativeIndexrefItemRelativeIndexAfterUpdate 应该指向同一条消息!

其实参照消息在理论上只要是 item0 以上的已经渲染的消息即可,即上述的参照消息的相对下标也可以是 234

注: 如果更新滚动视图后参照的消息无法得到渲染,则该功能就会失效,所以建议还是选择当前发生变化的消息的上一条消息的相对下标~

比较有意思的是,假设我们在发出问题后,往上翻页了,1秒后滚动视图渲染的消息是 item10item14

     trailing        relativeIndex
-----------------  -----------------
|     item14    |          4
|     item13    |          3
|     item12    |          2
|     item11    |          1
|     item10    |          0 
-----------------  -----------------
     leading

此时你的参照消息的相对下标就可以为 0 了,但是你需要判断当前已渲染的第一个 item 是否为生成式消息,这就很麻烦,没有必要。

总结来说,参照的消息不可以为发生变化的消息本身,而是为滚动视图在更新前后都会被渲染的 item 即可!

五、最后

通过上述示例的讲解,相信你对 scrollview_observer 的使用又更加清楚,如果你也觉得这个库好用,请不吝给个 Star 👍

GitHub: github.com/LinXunFeng/…

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~