算法遇记 | 字符串段拆插问题 - 富文本

3,753 阅读4分钟

1. 场景说明

最近遇到一个小问题,这里把问题模型简化,记录一下处理方式,也算是一个小纪念。先说一下场景,如下所示:

已知字符串 src
匹配段列表:matches

image.png


这样,在 Flutter 中可以通过对 matches 的遍历,形成富文本段,进行展示,效果如下:

image.png

TextSpan formSpan() {
  List<List<int>> matches = [
    [1, 2], [5, 8], [14, 15]
  ];
  String src = "toly 1994,hello!";
  List<InlineSpan> span = [];
  int cursor = 0;
  for (int i = 0; i < matches.length; i++) {
    List<int> match = matches[i];
    // 非匹配段
    String noMatchStr = src.substring(cursor, match[0]);
    span.add(TextSpan(text:noMatchStr , style: style0));
    // 匹配段
    String matchStr = src.substring(match[0], match[1]+1);
    span.add(TextSpan(text: matchStr, style: style1,));
    cursor = match[1]+1;
  }
  if (cursor != src.length - 1) {
    span.add(TextSpan(text: src.substring(cursor), style: style0));
  }
  return TextSpan(children: span);
}

2. 要解决的需求

现在有个需求,给定槽点列表 slots,在 保持原有匹配效果 的前提下,在每个槽点对应的索引处,插入该槽点的索引值,如下所示:

image.png

如下,是插入后的效果,其中原来的高亮样式保持不变,且在指定位置处额外插入了文字。这时候 有用怪 难抑心中疑问,发出灵魂呼喊:这有什么用呢?

image.png


如下所示,如果在定点插入的东西不是文字,而是其他组件,比如 FlutterLogo 。就完成了在不影响原有高亮匹配情况下,在指定槽位插入其他组件的能力:

image.png

说一个最直接的应用场景,如下代码高亮行号的插入,就是使用这种手段。不影响原有富文本,在定点插入指定组件。

代码高亮 + 行号代码高亮 + 行号
image.pngimage.png

3. 实现思路

这个问题的本质是根据 slots 点,对已字符段进行分割。就像一个拼接手术:首先找到位置,然后剪开,把插入段放在两片之间,再黏在一起:

image.png


由于槽点可以在任意位置,所以对于每段来说,操作都是一致的。这样对于每段字符,可以封装一个通用方法来处理。如下,定义 insertSlotWithBoundary 方法,传入每段的起止索引。第一步,应该校验当前段中是否存在槽点。如下左图所示,该段无槽点,就不需要进行什么处理:

image.png

这里定义 slotCursor 记录槽点数组的游标,它会随着每次槽点被处理,而自加。所以某段在处理时,通过 slots[slotCursor] 可以得到当前待入槽点位置。如下所示,当 slotCursor 长度大于大于总槽位时,说明已经插入完毕,不需要关注槽点了;或者 待入槽点位置 要比 end 还大,说明当前段没有槽点:

int slotCursor = 0;

insertSlotWithBoundary(int start, int end, TextStyle style) {

  if (slotCursor >= slots.length || slots[slotCursor] > end) {
    // 说明当前段没有槽点,无需处理
    span.add(TextSpan(
      text: src.substring(start, end),
      style: style,
    ));
    return;
  }
  // TODO 槽点处理
}

在某段中,可能存在 n 个槽点,把段分割为 n+1 段。结合 slotCursor 游标和 end 值,可以通过 while 循环进行遍历处理:

image.png

在进入循环时,将 slotCursor++,需要注意截取的终点需要额外处理一下。若干槽位已经结束,或下一槽位大于 end ,说明 下一槽点不再当前段。 将截取的终点设为 end :

insertSlotWithBoundary(int start, int end, TextStyle style) {
  // 同上,略...
  // 有槽点,分割插槽
  String matchStr = src.substring(start, slots[slotCursor]);
  span.add(TextSpan(text: matchStr, style: style));
  while (slots[slotCursor] < end) {
    int slotPosition = slots[slotCursor];
    slotCursor++;
    int currentEndPosition = 0;
    if (slotCursor == slots.length || slots[slotCursor] > end) {
      // 说明插槽结束
      // 说明下一槽点不再当前段
      currentEndPosition = end;
    } else {
      currentEndPosition = slots[slotCursor];
    }
    // 插入槽点组件:
    span.add(const WidgetSpan(child: FlutterLogo()));
    String matchStr = src.substring(slotPosition, currentEndPosition);
    span.add(TextSpan(
      text: matchStr,
      style: style,
    ));
    if (slotCursor >= slots.length) break;
  }
}

到这里,处理就完成了,虽然代码量比较少,但是其中需要考虑的点挺多的。包括校验条件、循环流程、游标处理等。在实现期间也走了不少弯路,试错花了不少时间,在调试中逐步解决问题。本以为我完成不了代码高亮的行号显示的,但在耐心和分析中还是写出来了,过程可谓是痛快的。

现在终于可以在 Flutter 中代码展示或者文本展示时加上行号了,仅以此文纪念这份自主解决问题的的愉悦感。下面是完整的 formSpan 方法,感兴趣的可以自己试一下:

TextSpan formSpan() {
  List<List<int>> matches = [[1, 2], [5, 8], [14, 15]];
  List<int> slots = [0, 2, 6, 8, 11, 13];
  String src = "toly 1994,hello!";
  List<InlineSpan> span = [];
  int cursor = 0;
  int slotCursor = 0;

  insertSlotWithBoundary(int start, int end, TextStyle style) {
    if (slotCursor>=slots.length||slots[slotCursor] > end) {
      // 说明当前段没有槽点,无需处理
      span.add(TextSpan(
        text: src.substring(start, end),
        style: style,
      ));
      return;
    }
    // 有槽点,分割插槽
    String matchStr = src.substring(start, slots[slotCursor]);
    span.add(TextSpan(text: matchStr, style: style));
    while (slots[slotCursor] < end) {
      int slotPosition = slots[slotCursor];
      slotCursor++;
      int currentEndPosition = 0;
      if (slotCursor == slots.length || slots[slotCursor] > end) {
        // 说明插槽结束
        // 说明下一槽点不再当前段
        currentEndPosition = end;
      } else {
        currentEndPosition = slots[slotCursor];
      }
      span.add(const WidgetSpan(child: FlutterLogo()));
      String matchStr2 = src.substring(slotPosition, currentEndPosition);
      span.add(TextSpan(
        text: matchStr2,
        style: style,
      ));
      if (slotCursor >= slots.length) break;
    }
  }

  for (int i = 0; i < matches.length; i++) {
    List<int> match = matches[i];
    insertSlotWithBoundary(cursor, match[0], style0);
    insertSlotWithBoundary(match[0], match[1] + 1, style1);
    cursor = match[1] + 1;
  }
  if (cursor != src.length - 1) {
    insertSlotWithBoundary(cursor,src.length, style0);
  }
  return TextSpan(children: span);
}