You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter使用RichText含特殊字符时触发UTF-16格式错误求助

Flutter中RichText高亮搜索结果触发UTF-16格式错误的分析与解决

错误信息

═════════ Exception caught by painting library ══════════════════════════════════
Invalid argument(s): string is not well-formed UTF-16

问题场景

在Flutter应用的ListView内使用RichText组件实现搜索结果高亮时触发上述错误,搜索"he"等内容时都会出现该问题,推测与Unicode字符/Emoji的处理逻辑有关。

相关代码

class MyApp09 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<String> hugeStrings = [
   " Hey there😀😃😃😃😃😃😃😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀"
  ];

  TextEditingController _searchController = TextEditingController();
  String _searchText = '';
  List<String> searchResults = [];

  @override
  void initState() {
    super.initState();
    _searchController.addListener(() {
      setState(() {
        _searchText = _searchController.text.toLowerCase();
        searchResults = hugeStrings
            .where((string) => string.toLowerCase().contains(_searchText))
            .toList();
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Search Example"),
      ),
      body: Column(
        children: [
          TextField(
            controller: _searchController,
            decoration: InputDecoration(
              hintText: 'Search...',
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: searchResults.length,
              itemBuilder: (context, index) {
                return Container(
                  color: Colors.red, // Set background color
                  padding: EdgeInsets.all(8.0),
                  child: _highlightText(searchResults[index], _searchText),
                );
              },
            ),
          ),
        ],
      ),
    );
 
  }

 Widget _highlightText(String text, String highlight,
    {int maxCharactersBefore = 20, int maxCharactersAfter = 20}) {
  // Convert both text and highlight to lowercase for case-insensitive search
  String lowercaseText = text.toLowerCase();
  String lowercaseHighlight = highlight.toLowerCase();

  // Create a list to store the parts of the text
  List<TextSpan> children = [];

  // Find all occurrences of the highlight substring at the beginning of a word in the text
  RegExp regex =
      RegExp(r'\b' + lowercaseHighlight + r'\w*', caseSensitive: false);
  Iterable<Match> matches = regex.allMatches(lowercaseText);

  // Track if the first match is found
  bool firstMatchFound = false;

  // Track the total characters processed
  int totalProcessedCharacters = 0;

  // Iterate through each match and add it to the children list with highlighting
  for (Match match in matches) {
    // Check if the match is at the beginning of the text or after a non-word character
    if (match.start == 0 ||
        !lowercaseText
            .substring(match.start - 1, match.start)
            .contains(RegExp(r'\w'))) {
      // Calculate the start and end indices for the substring to show
      int index = match.start;
      int start = index - maxCharactersBefore;
      int end = index + highlight.length + maxCharactersAfter;

      // Adjust start and end indices if they exceed the bounds of the text
      start = start < 0 ? 0 : start;
      end = end > text.length ? text.length : end;

      // Add "..." if there are characters before the matched text
      if (start > totalProcessedCharacters) {
        children.add(TextSpan(text: '...'));
      }

      // Add the text before the match
      children.add(TextSpan(text: text.substring(start, index)));

      // Add the matched text with highlighting
      children.add(TextSpan(
        text: text.substring(index, index + highlight.length),
        style: TextStyle(backgroundColor: Colors.yellow),
      ));

      // Add the text after the match
      children
          .add(TextSpan(text: text.substring(index + highlight.length, end)));

      // Add "..." if there are characters after the matched text
      if (end < text.length && totalProcessedCharacters < end) {
        children.add(TextSpan(text: '...'));
      }

      // Set firstMatchFound to true
      firstMatchFound = true;

      // Break the loop after finding the first match
      break;
    }
  }

  // If no match found or first match is not at the beginning of a word, check if the searched character exists
  if (!firstMatchFound && highlight.isNotEmpty) {
    // Find all occurrences of the searched character in the text
    RegExp charRegex = RegExp(highlight, caseSensitive: false);
    Iterable<Match> charMatches = charRegex.allMatches(lowercaseText);

    // Iterate through each match and add it to the children list with highlighting
    for (Match match in charMatches) {
      // Calculate the start and end indices for the character to highlight
      int index = match.start;

      // Add the text before the character
      children.add(TextSpan(text: text.substring(0, index)));

      // Add the matched character with highlighting
      children.add(TextSpan(
        text: text.substring(index, index + 1),
        style: TextStyle(backgroundColor: Colors.yellow),
      ));

      // Add the text after the character
      children.add(TextSpan(text: text.substring(index + 1)));

      // Set firstMatchFound to true
      firstMatchFound = true;

      // Break the loop after finding the first match
      break;
    }
  }

  // If no match found, return the original text
  if (!firstMatchFound) {
    return Text(text);
  }

  // Create a RichText widget with the highlighted spans
  return RichText(
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
    text: TextSpan(children: children),
  );
}

问题原因

  • 核心矛盾:Flutter的String.substring方法基于UTF-16代码单元索引切割字符串,但Emoji等复杂Unicode字符是由多个UTF-16代码单元组成的(比如😀占用2个代码单元)。
  • 触发点:当_highlightText方法中的start、index等切割位置刚好落在Emoji字符的代码单元中间时,截取的子串会包含不完整的Unicode字符,导致系统抛出"string is not well-formed UTF-16"错误。

解决办法

使用Flutter内置的characters库处理字符串,它会基于**Unicode字符(grapheme clusters)**进行索引和切割,确保不会破坏多代码单元的字符。

修改后的代码

class MyApp09 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<String> hugeStrings = [
   " Hey there😀😃😃😃😃😃😃😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀"
  ];

  TextEditingController _searchController = TextEditingController();
  String _searchText = '';
  List<String> searchResults = [];

  @override
  void initState() {
    super.initState();
    _searchController.addListener(() {
      setState(() {
        _searchText = _searchController.text.toLowerCase();
        searchResults = hugeStrings
            .where((string) => string.toLowerCase().contains(_searchText))
            .toList();
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Search Example"),
      ),
      body: Column(
        children: [
          TextField(
            controller: _searchController,
            decoration: InputDecoration(
              hintText: 'Search...',
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: searchResults.length,
              itemBuilder: (context, index) {
                return Container(
                  color: Colors.red,
                  padding: EdgeInsets.all(8.0),
                  child: _highlightText(searchResults[index], _searchText),
                );
              },
            ),
          ),
        ],
      ),
    );
 
  }

 Widget _highlightText(String text, String highlight,
    {int maxCharactersBefore = 20, int maxCharactersAfter = 20}) {
  final textChars = text.characters;
  final lowercaseText = text.toLowerCase();
  final lowercaseHighlight = highlight.toLowerCase();

  List<TextSpan> children = [];

  // 匹配单词开头的高亮内容
  RegExp regex = RegExp(r'\b' + lowercaseHighlight + r'\w*', caseSensitive: false);
  Iterable<Match> matches = regex.allMatches(lowercaseText);

  bool firstMatchFound = false;

  for (Match match in matches) {
    if (match.start == 0 ||
        !lowercaseText.substring(match.start - 1, match.start).contains(RegExp(r'\w'))) {
      // 将Match的UTF-16索引转换为Unicode字符索引
      final charIndex = textChars.getRange(0, match.start).length;
      final charStart = charIndex - maxCharactersBefore;
      final charEnd = charIndex + highlight.characters.length + maxCharactersAfter;

      final adjustedStart = charStart < 0 ? 0 : charStart;
      final adjustedEnd = charEnd > textChars.length ? textChars.length : charEnd;

      if (adjustedStart > 0) {
        children.add(const TextSpan(text: '...'));
      }

      // 基于Unicode字符截取
      children.add(TextSpan(
        text: textChars.getRange(adjustedStart, charIndex).toString(),
      ));

      children.add(TextSpan(
        text: textChars.getRange(charIndex, charIndex + highlight.characters.length).toString(),
        style: const TextStyle(backgroundColor: Colors.yellow),
      ));

      children.add(TextSpan(
        text: textChars.getRange(charIndex + highlight.characters.length, adjustedEnd).toString(),
      ));

      if (adjustedEnd < textChars.length) {
        children.add(const TextSpan(text: '...'));
      }

      firstMatchFound = true;
      break;
    }
  }

  // 匹配单个字符的情况
  if (!firstMatchFound && highlight.isNotEmpty) {
    RegExp charRegex = RegExp(highlight, caseSensitive: false);
    Iterable<Match> charMatches = charRegex.allMatches(lowercaseText);

    for (Match match in charMatches) {
      final charIndex = textChars.getRange(0, match.start).length;

      children.add(TextSpan(
        text: textChars.getRange(0, charIndex).toString(),
      ));

      children.add(TextSpan(
        text: textChars.getRange(charIndex, charIndex + 1).toString(),
        style: const TextStyle(backgroundColor: Colors.yellow),
      ));

      children.add(TextSpan(
        text: textChars.getRange(charIndex + 1, textChars.length).toString(),
      ));

      firstMatchFound = true;
      break;
    }
  }

  if (!firstMatchFound) {
    return Text(text);
  }

  return RichText(
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
    text: TextSpan(children: children),
  );
}

修改说明

  1. 引入text.characters将字符串转换为Unicode字符序列,所有索引和截取操作都基于这个序列。
  2. 将Match对象的UTF-16索引(match.start)转换为Unicode字符索引:通过textChars.getRange(0, match.start).length计算得到对应的字符位置。
  3. 替换所有substring调用为textChars.getRange(start, end).toString(),确保截取的子串都是完整的Unicode字符。

内容的提问来源于stack exchange,提问作者user21055008

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.25 04:59:50