这一阵工作有点忙,同时也不知道分享些什么内容,所以就决定翻译一篇文章。刚好本文的内容在刚发布的1.20版本中已经部分落地。之前在Flutter开发中的一些Tips(二)中我也提到了这个问题,开篇带大家回忆一下:
具体为什么会这样,往下看吧!希望本篇能给我们开发中提供启发和帮助,推荐阅读。
原文:Dart string manipulation done right
作者:Tao Dong
在表情符号开始主导我们的日常交流和多语言支持在商业应用中兴起之前,Dart与许多其他编程语言一样,将字符串表示为UTF-16编码单元序列。这种编码在大多数情况下都能良好运行,直到国际化程度的提高和与任何语言兼容的表情符号的引入,使得编码的固有问题变成了每个人的问题。
考虑一下这个例子:
在字符串“Hello👋”中,除了挥手表情👋外,每个字符被映射到一个编码单元。这种映射的直接后果是混淆了这个字符串的长度。那么下面这一行代码的输出是6还是7?
print('Hello👋'.length);
对于用户来说,这个字符串中显然有6个字符,除非你很理性。但是Dart的 String
API会告诉您长度是7,或者更精确地说,是7个UTF-16编码单元。这种差异有各种各样的后果,因为许多文本操作任务都涉及到在String
API中使用字符索引。例如,“"Hello👋"[5]
不会返回👋这个表情符号。相反,它将返回一个畸形字符,也就是这个表情符号的第一个编码单元(55357)。
好消息是,Dart有一个名为characters的新包,用于操作用户可感知的字符,而不是UTF-16编码单元。但是,作为一个Dart程序员,你需要知道什么时候使用characters
包。我们的研究表明,即使是经验丰富的Dart程序员在读取文本操作代码时也很容易忽略这类问题。在本文中,我将介绍一些常见的场景,在这些场景中,您需要格外注意并考虑使用characters
包而不是DartString
。
需要注意的场景
在这一节中,我将介绍几个常见的文本操作场景,解释为什么在这些场景中使用Dart的 String
API会导致问题,并展示如何使用characters
包获得更可靠的结果。下面的用例通常假设我们处理的是用户输入的字符串,其中可能包括表情符号或其他语言中的字符。
场景1:计算字符串中的字符数
假设您正在编写一个函数来检查用户输入的文本是否超过了特定数量的字符。这个函数会返回剩余的数量。
使用String
API可以非常简单的实现:
// 使用String API实现,该API计算UTF-16编码单元的数量,而不是用户可感知的字符。
int remainingCapacity(String input, int limit) {
var length = input.length;
return limit - length;
}
但是,下面的测试揭示了这段代码的问题:
test('remainingCapacity', (){
var limit = 140;
input = 'Laughter 😀 is the sensation of feeling good all over and showing it principally in one place.';
expect(remainingCapacity(input, limit), equals(47));
});
以下是测试结果:
Expected: <47>
Actual: <46>
我们可以使用characters
包重写这个函数,它为 String
提供了一个方便的扩展方法,以生成正确的字符数,如下所示:
int checkMaxLength(String input, int limit) {
var length = input.characters.length;
return limit - length;
}
场景2:提取子字符串
在这个场景中,我们希望实现一个函数,该函数从字符串中删除最后一个字符,并将结果作为新字符串返回。让我们假设这个字符串来自用户输入。
这个函数使用String
上的substring
方法很容易实现,如下所示:
String skipLastChar(String text) {
return text.substring(0, max(0, text.length - 1));
}
不过,在测试中一个表情符号就会破坏这段代码:
test('skipLastChar(text)', () {
var string = 'Hi 🇩🇰';
expect(skipLastChar(string), equals('Hi '));
});
以下是测试结果:
Expected: ‘Hi ’
Actual: ‘Hi 🇩???’
Which: is different. Both strings start the same, but the actual value also has the following trailing characters: 🇩???
characters
包可以轻松处理这种情况,因为它提供了诸如skipLast(int count)等高级方法。我们可以将这段代码重写为以下代码:
String skipLastChar(String text) {
return text.characters.skipLast(1).toString();
}
场景3:在表情符号上拆分字符串
在第三个场景中,我们想要在一个给定的表情符号上分割一个字符串。这里是一个使用String
上的split
来实现的函数。
List splitEmojiSeparatedWords(String text, String separator) {
return text.split(separator);
}
它会工作吗?它可能在99%的情况下都能正常工作,但是下面的测试演示了一个例子,在这个例子中,上面的代码会产生相当惊人的结果。
test('splitEmojiSeparatedWords(String text, String separator)', () {
var text = 'abc👨👩👧👦👧abc👧abc👧abc';
var separator = '👧';
List<String> expected = ['abc👨👩👧👦', 'abc', 'abc', 'abc'];
expect(td.splitEmojiSeparatedWords(text, separator), equals(expected));
});
以下是测试结果:
Expected: ['abc👨👩👧👦', 'abc', 'abc', 'abc']
Actual: ['abc👨👩', '👦', 'abc', 'abc', 'abc']
Which: was 'abc👨👩' instead of 'abc👨👩👧👦' at location [0]
为什么当字符串被拆分后👨👩👧👦成为两个表情符号👨👩?这是因为👨👩👧👦实际上是由四个不同的表情符号组成:👨👩👧👦。当从👧的位置拆分时,”abc👨👩👧👦”分为两个部分:“abc👨👩”和“👦”。
你可以通过使用Characters
的 split 方法来避免这个问题,如下代码所示:
List<String> splitEmojiSeparatedWords(String text, String separator) {
// Split返回一个iterable,我们需要将其转换为列表。
return [...text.characters.split(separator.characters)];
}
场景4:通过索引访问特定字符
在文本操作中,通过索引访问特定字符是很常见的。在下面的函数中,该函数返回用户输入的名和姓的首字母:
String createInitials(String firstName, String lastName) {
return firstName[0].toUpperCase() + lastName[0].toUpperCase();
}
但是,正如我们在本章开头所演示的,在基于UTF-16的字符串中使用索引可能会有风险。让我们用下面的测试用例来验证以上代码的正确性:
test("createInitials(firstName, lastname)", () {
var firstName = 'étienne';
var lastname = 'bézout';
expect(td.createInitials(firstName, lastname), equals('ÉB'));
});
以下是测试结果:
Expected: ‘ÉB’
Actual: ‘EB’
Which: is different.
为什么测试失败了?因为字母“É”可能是一个“E”和重音符号的组合。你可以使用characters
包来轻松避免这个问题:
String createInitials(String firstName, String lastName) {
return '${firstName.characters.first}${lastName.characters.first}';
}
练习:省略文本溢出
现在,给你一个挑战。在这个场景中,应用需要显示一个消息列表,每行一条消息。要求你实现一个函数,该函数在消息长度超过给定字符限制时,将文本溢出部分显示为省略号。
String textOverflowEllipsis(String text, int limit) {
if (text.length > limit) {
return text.substring(0, limit - 3) + '…';
} else {
return text;
}
}
你能想出一个测试来揭示这个代码的潜在问题吗?你将如何使用characters
包来重写它?答案在本文的最后。
缓解方法和可能的长期解决方案
期望Dart用户对上述各种陷阱保持高度警惕是不合理的。例如,在我们进行的一项实验中,53.7%的Dart用户无法检测到第一个场景中所描述的问题,尽管他们收到了两页关于characters
包以及该包设计出来解决的问题说明。因此,我们采用两阶段的方法来帮助开发人员为他们的文本操作需求选择最合适的API。
短期内,我们将在Flutter框架和Dart分析器中引入一套缓解措施,以使characters
包在Dart UI编程中更容易被发现和调用。这包括几个步骤:
- 在
TextField
的内部实现中使用characters
包。更多细节请参见PR和设计文档。 - 通过Flutter框架暴露
characters
包的API。一旦完成,Flutter用户将有更多的机会通过扩展方法String.characters发现API,它将在对String
进行自动完成时显示出来。进度跟踪见:https://github.com/flutter/flutter/issues/55593。 - 更新Flutter框架的API文档和示例代码,建议在合适的场景使用Characters 类,比如在回调 TextField.onChanged中,该工作可在https://github.com/flutter/flutter/issues/55598中跟踪,相关细节见此文档。
- 当自动完成回调模板处理用户输入的文本时,让Dart分析器建议将一个
String
对象转换为Characters
对象。例如,在用户自动完成onChanged
之后,IDE可以填写下面代码片段中的所有内容。这项工作可以在https://github.com/dart-lang/sdk/issues/41677进行跟踪。
TextField(
onChanged: (String value) {
// 将字符串转换为字符,以更健壮地处理表情符号和非英语字符。
var myText = value.characters;
}
)
这些缓解可以有所帮助,但是它们仅限于在Flutter项目上下文中执行的字符串操作。在它们可用之后,我们需要仔细衡量它们的有效性。在Dart语言级别上,一个更完整的解决方案可能至少需要迁移一些现有代码,尽管有一些选项(例如,静态扩展类型)可能会使破坏性的更改易于管理。这需要进行更多的技术调查,以充分理解其中的利弊。
如何提供帮助
请帮助我们提高意识,如何使用characters
包修复字符串问题:
- 在你的代码中寻找使用
String.length
或String.substring
的地方。如果字符串可能来自用户输入,尝试使用characters
包重写代码。 - 与Dart社区的其他人分享这篇文章。(译者PS:我这不就分享了吗,哈哈!)
- 尝试在StackOverflow上更新现有的关于Dart文本操作的答案。如果现在的答案忽略了
String
API的这个限制,提醒人们注意风险。 - 对上面列出的GitHub问题发表评论,让我们知道你的想法和意见。
现在,祝大家编码快乐😉!
感谢
感谢 Kathy Walrath,Lasse Nielsen和 Michael Thomson 对本文的评论。我还要感谢参与我们用户研究的开发人员。他们的参与帮助了Dart和Flutter团队更好地理解了DartString
API处理这类问题的局限性。
PS: 这里是这个练习的解决方案:
// Prerequisite: add the characters package as a dependency in your pubspec.yaml.
import 'package:characters/characters.dart';
void main(List<String> arguments) {
print(textOverflowEllipsis('😸cats', 10));
print(textOverflowEllipsis('🦏rhinoceroses', 10));
}
// This function converts text overflow to an ellipsis
// when the text's length exceeds the given character limit.
String textOverflowEllipsis(String text, int limit) {
var myChars = text.characters;
if (myChars.length > limit) {
return '${myChars.take(limit - 1)}…';
} else {
return text;
}
}