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), ); }
修改说明
- 引入
text.characters将字符串转换为Unicode字符序列,所有索引和截取操作都基于这个序列。 - 将
Match对象的UTF-16索引(match.start)转换为Unicode字符索引:通过textChars.getRange(0, match.start).length计算得到对应的字符位置。 - 替换所有
substring调用为textChars.getRange(start, end).toString(),确保截取的子串都是完整的Unicode字符。
内容的提问来源于stack exchange,提问作者user21055008
相关产品推荐
相关产品推荐

