likes
comments
collection
share

高性能易拓展文本链接、表情、电话、邮箱地址高亮解析

作者站长头像
站长
· 阅读数 10

说点必要的题外话

现在很多场景需要进行文本解析,我之前也分享过一篇文章,这篇文章提供了一个思路,按照这个思路基本上也可以实现文本中的链接、表情、邮箱地址、电话等解析。如下图。

高性能易拓展文本链接、表情、电话、邮箱地址高亮解析

pub.dev有一个相对比较火的三方库【flutter_parsed_text】,其内部源码实现也是这个方案。但是如果大家再较真一点,会发现这个方案至少有两点不太优秀,或者说高性能。

Widget build(BuildContext context) {  //tag1
  ...
  newString.splitMapJoin(
    RegExp(
      pattern, //tag2
    ),
    onMatch: (Match match) {
      final matchText = match[0];

      final mapping = _mapping[matchText!] ??
          _mapping[_mapping.keys.firstWhere((element) {
            final reg = RegExp(
              element,
            );
            return reg.hasMatch(matchText); //tag3
          }, orElse: () {
            return '';
          })];

      InlineSpan widget;

以上源码来自代码来自 flutter_parsed_text/lib/src/parsed_text.dart, 我省略了部分无关代码,重点看下标注的tag1tag2tag3三处,会发现有两处还可以再优化。

  1. 相同内容文本正则匹配需要进行两次计算
  2. 每次页面build的时候,都会重新进行两次文本解析

针对这两个性能问题,我就在想,能不能把文本解析的计算复杂度从2*O(n), 降低到O(n), 甚至到O(1)。 办法还是有的,只要多尝试。

总体思路

文本解析,有3个要素

高性能易拓展文本链接、表情、电话、邮箱地址高亮解析

  1. 需要解析的类型,比如链接,表情,命令等。这个可以定义一个枚举,或者直接用int整形,保证唯一就行。
  2. 正则表达式,这个没什么好解释的。
  3. 解析成功失败需要展示的样式,这个可以通过Widget builder(MatchInfo info)这种形式的回调来解决,MatchInfo为解析的结果段,下面代码部分我们详细说。

理解了这3要素,下面我想说说我理解的文本解析,何为高性能和易拓展。

  1. 文本每build的时候,正则匹配只计算一次
  2. 支持多种解析,通过简单的热插拔就可以新增解析类型,而不是去添加更多的else if

思路和目标都说了,下面我们一起来实现吧。

开始编码

1. 定义一个文本解析抽象类XHHighlightMatch

他实现了上一节中说的正则3要素,匹配类型,匹配规则(正则),匹配结果展示形式。下面的代码中有几个小细节我说一下。

首先这个是一个抽象类,使用方需要继承并实现,这样式设计的目的就是方便使用方更加容易拓展不同的匹配类型。

matchType是用户自定义的,不同规则继承这个XHHighlightMatch抽象类之后,覆写并保持唯一。

matchBuilder有一个XHMatchInfo类型的参数。这个对象用来保存匹配结果。下一步就是定义这个对象。

abstract class XHHighlightMatch {
  /// 匹配组件生成器
  InlineSpan matchBuilder(XHMatchInfo matchInfo);

  /// 匹配正则,不匹配返回null
  Pattern? matchReg();

  /// 匹配类型,不同的匹配需要保证唯一值
  int get matchType;
}

2. 定义一个解析结果对象XHMatchInfo

XHMatchInfo这个对象很简单,主要是为了用来保持匹配结果。字段含义已经写了注释。

高性能易拓展文本链接、表情、电话、邮箱地址高亮解析

class XHMatchInfo {
  /// 匹配的类型
  final int matchType;

  /// 匹配结果在原字符串中的起始位置
  final int start;

  /// 匹配结果在原字符串中的结束位置
  final int end;

  /// 匹配结果
  final String value;

  XHMatchInfo({
    required this.matchType,
    required this.start,
    required this.end,
    required this.value,
  });
}

3. 核心解析部分

前面两步把需要用到的工具类实现了,下面来实现核心部分。

3.1 定义一个匹配管理对象HighlightMatchManager

这个对象的属性其实就2个,一个是匹配列表,一个是不匹配的默认样式。里面一个字典是内部使用的,其实就是Map<matchType, XHHighlightMatch>

class HighlightMatchManager {
  /// 需要正则匹配列表
  final List<XHHighlightMatch> matchList;

  /// 不匹配样式
  final XHHighlightMatch unMatch;

  /// 需要匹配的数组转为字典,方便内部使用
  final Map<int, XHHighlightMatch> _toMatchMap = {};

  // HighlightMatchManager({required this.matchList, required this.unMatch});
  HighlightMatchManager({required this.matchList, required this.unMatch}) {
    for (XHHighlightMatch item in matchList) {
      if (item.matchReg() != null) {
        _toMatchMap.putIfAbsent(item.matchType, () => item);
      }
    }
    debugPrint("HighlightMatchManager create: ${_toMatchMap}");
  }
  
  //...
}

3.2 开始实现文本匹配解析

这里面主要的知识要点就是

StringScanner,这个类来自string_scanner,主要用来检索字符串小工具。这个类有一个检索位置属性positionisDone,就可以来循环需要解析的文本了。

pattern.matchAsPrefix(string, position); 这个正则方法可以根据设置的position为起始,来匹配。scanner.scan(reg)内部其实就是利用这个方法来来匹配指定位置是否匹配输入的正则参数。

 List<XHMatchInfo> startMatch(String target) {
    StringScanner scanner = StringScanner(target);

    List<XHHighlightMatch> rules = _toMatchMap.values.toList();
    List<int> types = _toMatchMap.keys.toList();
    List<XHMatchInfo> tmpMatchList = [];

    while (!scanner.isDone) {
      bool hasMatch = false;
      for (int i = 0; i < rules.length; i++) {
        if (scanner.scan(rules[i].matchReg()!)) {
          Match? match = scanner.lastMatch;
          if (match != null) {
            tmpMatchList.add(XHMatchInfo(
              matchType: types[i],
              start: match.start,
              end: match.end,
              value: match.group(0) ?? '',
            ));
          }
          hasMatch = true;
          break;
        }
      }
      if (scanner.isDone) break;
      if (!hasMatch) scanner.position++;
    }

假如我们写需要匹配表情,我们定义表情的形式是:[笑脸]。通过上一步,其实我们就可以在tmpMatchList这个临时解析结果数组中匹配到下图中的第二部分。 然后未匹配成功的,我们只需要利用一个游标变量cursor=0和遍历tmpMatchList,使用curson到数组中每一项的的start进行过拼接就行了。最终我们会得到一个长度为3的解析结果。细节可以看下图,表意应该很清晰了。

高性能易拓展文本链接、表情、电话、邮箱地址高亮解析

接在上面函数的下面继续写,这样就得到了我们的匹配结果列表,无论匹配成功还是失败,都在这个列表中。

 List<XHMatchInfo> allMatchList = [];
    int cursor = 0;
    for (int i = 0; i < tmpMatchList.length; i++) {
      XHMatchInfo match = tmpMatchList[i];
      if (cursor != match.start) {
        allMatchList.add(XHMatchInfo(
            matchType: unMatch.matchType,
            start: cursor,
            end: match.start,
            value: target.substring(cursor, match.start)));
      }
      allMatchList.add(match);
      cursor = match.end;
    }
    if (cursor != target.length - 1) {
      allMatchList.add(XHMatchInfo(
          matchType: unMatch.matchType,
          start: cursor,
          end: target.length - 1,
          value: target.substring(cursor)));
    }
    return allMatchList;

有了上面一个列表,剩下的事情就简单了。需要生成一个一个InlineSpan,然后放到RichText组件中就行。话不多说,直接看代码。 _toMatchMap[match.matchType]!.matchBuilder(match) 其实就是调用我们之前那个抽象类中定义的方法。

 List<InlineSpan> generateTextInlineSpans(List<XHMatchInfo> infoList) {
    List<InlineSpan> span = [];
    for (int i = 0; i < infoList.length; i++) {
      XHMatchInfo match = infoList[i];
      if (_toMatchMap.containsKey(match.matchType)) {
        span.add(_toMatchMap[match.matchType]!.matchBuilder(match));
      } else {
        span.add(unMatch.matchBuilder(match));
      }
    }
    return span;
  }

为了通用简单,我们再封装一个方法,调用上面提供的两个核心方法,这样就可以一键解析了。

List<InlineSpan> matchThenGenInlineSpan(String target) {
  final infoList = startMatch(target);
  return generateTextInlineSpans(infoList);
}

到此,我们已经完成了文本解析。大家发现没有,整个过程其实都是面向接口编程,这样的好处就可以很方便模块化解耦。使用其实就是实现这个接口。

使用!

使用其实就是实现之前定义的抽象类接口。

1. 不匹配默认样式

这个很好理解,就是不匹配的文本展示样式,这个是必须实现的。

class HighlightUnMatch extends XHHighlightMatch {
  @override
  InlineSpan matchBuilder(XHMatchInfo matchInfo) {
    return TextSpan(
        text: matchInfo.value,
        style: const TextStyle(fontSize: 16, color: Colors.black));
  }

  @override
  Pattern? matchReg() {
    return null;
  }

  @override
  int get matchType => -1;
}

2. 匹配链接

class URLMatch extends XHHighlightMatch {
  @override
  InlineSpan matchBuilder(XHMatchInfo matchInfo) {
    return WidgetSpan(
      alignment: PlaceholderAlignment.middle,
      child: GestureDetector(
          onTap: () {
            print('to open url: ${matchInfo.value}');
          },
          child: Text(
            matchInfo.value,
            style: const TextStyle(
                color: Colors.blue, fontSize: 16, fontWeight: FontWeight.bold),
          )),
    );
  }

  @override
  Pattern? matchReg() {
    return RegExp(
        r"(http(s)?)://[a-zA-Z\d@:._+~#=-]{1,256}.[a-z\d]{2,18}\b([-a-zA-Z\d!@:_+.~#?&/=%,$]*)(?<![$])");
  }

  @override
  int get matchType => 2;
}

3. 匹配邮箱地址

class EmailMatch extends XHHighlightMatch {
  @override
  InlineSpan matchBuilder(XHMatchInfo matchInfo) {
    return (TextSpan(
        text: matchInfo.value,
        style: const TextStyle(color: Colors.pink, fontSize: 16)));
  }

  @override
  Pattern? matchReg() {
    return RegExp(r"\b[\w.-]+@[\w.-]+.\w{2,4}\b");
  }

  @override
  int get matchType => 3;
}

4. 匹配表情

class EmoMatch extends XHHighlightMatch {
 ...
}

5. 初始化解析器

前面实现了3种匹配对象和1种不匹配对象, 如果你需要再匹配其他的类型,按照相同的方法添加就行了。是不是很简单!

final matchManager = HighlightMatchManager(matchList: [
  EmailMatch(),
  EmoMatch(),
  URLMatch(),
], unMatch: HighlightUnMatch());

6.创建组件

Widget build(BuildContext context) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      RichText(
          text: TextSpan(
              children: matchManager.matchThenGenInlineSpan(
                  "@!280849192149057536你好[爱心][呲牙] 看链接 https://mp.weixin.qq.com 邮箱地址: leaf@test.com"))),
      RichText(
          text: TextSpan(
              children: matchManager.matchThenGenInlineSpan("[你好][笑脸]")))
    ],
  );
}

效果图

高性能易拓展文本链接、表情、电话、邮箱地址高亮解析

小结一下

经过上面的方法,我们已经做到了第一个目标,把匹配计算复杂度由2*O(n)降到O(n)。复杂度降低一倍,至少说明CPU对于这个工作降低了一倍。对于一些IM类的APP,消息列表页面,会有大量的文本解析,复杂度降低就降低了手机的能耗。

高性能易拓展文本链接、表情、电话、邮箱地址高亮解析

好,我们继续,看如何把这个计算度O(1)

我相信其实很多人已经想到如何把计算度再由O(n)降低到O(1)了,说白了就是用空间换时间。

前面我们的HighlightMatchManager对象的特意写了两个方法,startMatch()用来生成解析结果数组,其实只需要把这个结果保存下,下次Widget重新build的时候,直接从内存中拿到这个解析结果数组,然后调用generateTextInlineSpans这个方法就可以直接生成对象的组件树了。

这样处理之后,IM App的消息列表页面上下滑动,文本解析只需要匹配一次就行了。当然这个空间也不能无限加大,可以简单实现一个定长容量的队列就行了。

关于Flutter高性能编程和Flutter调优工具使用,大家可以看看这篇文章 Flutter调优工具使用及Flutter高性能编程部分要点分析

同系列文章 Flutter文本解析,轻松消息卡片实现链接,表情,命令解析

好了,各位!就说到这吧。附上源码