0%

【文章翻譯】Dart string manipulation done right

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

正確操作 Dart 字串 👉

像許多其他在表情符號開始主導我們的日常通訊和多語言支援在商業應用程式中興起之前設計的程式語言一樣,Dart 將字串表示為 UTF-16 編碼單元序列。這種編碼在大多數情況下都能正常工作,直到國際化的加強和伴隨任何語言出現的表情符號的引入,使得該編碼的固有問題成為每個人的問題。

考慮這個例子:

The image shows the string “Hello” with a handwaving emoji at the end and it’s UTF-16 code units. The emoji takes two units.

在字串 “Hello👋” 中,除了揮手表情符號 👋 之外,每個使用者可感知的字元都映射到一個編碼單元。這種映射的直接後果是對該字串長度的混淆。以下程式碼行的輸出是 6 還是 7?

1
print('Hello👋'.length);

對於使用者來說,這個字串中顯然有 6 個字元,除非你進行哲學思考。但 Dart String API 會告訴你 長度 是 7,或者更準確地說,是 7 個 UTF-16 編碼單元。這種差異會產生各種各樣的後果,因為許多文字操作任務都涉及到使用 String API 的字元索引。例如,”Hello👋”[5] 不會返回 👋 表情符號。相反,它會返回一個表示表情符號第一個編碼單元的錯誤字元。

好消息是,Dart 有一個名為 characters 的新套件,它操作使用者可感知的字元,而不是 UTF-16 編碼單元。但是,作為 Dart 程式設計師,您需要知道何時使用 characters 套件。我們的研究表明,即使是經驗豐富的 Dart 程式設計師在閱讀文字操作程式碼時也很容易忽略此類問題。在本文中,我將介紹一些常見的場景,在這些場景中您需要注意並考慮使用 characters 套件而不是 Dart String。

需要注意的場景

在本節中,我將介紹一些常見的文字操作場景,解釋為什麼在這些場景中使用 Dart 的 String API 可能會導致問題,並展示如何使用 characters 套件獲得更可靠的結果。以下用例通常假設我們正在處理人類使用者輸入的字串,其中可能包含表情符號或應用程式開發人員未預期的語言的字元。

場景 1:計算字串中的字元數

假設您正在撰寫一個函數,用於檢查使用者輸入的文字是否超過了特定數量的字元。如果未達到限制,則該函數返回剩餘字元的正數;如果超過了限制,則返回額外字元的負數。

使用 String API 執行此操作非常簡單:

1
2
3
int remainingCharacters(String text, int limit) {
return limit - text.length;
}

但是,以下測試揭示了此程式碼的問題:

1
2
3
test('remainingCharacters emoji', () {
expect(remainingCharacters('How are you doing today 😀', 50), 47);
});

以下是測試結果:

1
2
Expected: <47>
Actual: <46>

我們可以使用 characters 套件重寫此函數,該套件在 String 上提供了一個方便的擴展方法,以產生正確的字元數,如下所示:

1
2
3
int remainingCharacters(String text, int limit) {
return limit - text.characters.length;
}

場景 2:擷取子字串

在此場景中,我們想要實作一個函數,該函數從字串中刪除最後一個字元並將結果作為新字串返回。讓我們假設此字串來自使用者輸入。

使用 String 上的 substring 方法可以輕鬆實作此函數,如下所示:

1
2
3
String deleteLastCharacter(String text) {
return text.substring(0, text.length - 1);
}

但是,一個好的表情符號測試可以快速破壞程式碼:

1
2
3
test('deleteLastCharacter emoji', () {
expect(deleteLastCharacter('Hi 🇩🇪'), 'Hi ');
});

以下是測試結果:

1
2
3
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)。我們可以將此程式碼片段重寫為以下程式碼:

1
2
3
4
String deleteLastCharacter(String text) {
return text.characters.skipLast(1).toString();
}

場景 3:根據表情符號分割字串

在第三個場景中,我們想要根據給定的表情符號分割字串。以下是一個使用 String 上的 split 方法執行此操作的函數:

1
2
3
List<String> splitString(String text, String emoji) {
return text.split(emoji);
}

它會起作用嗎?它可能在 99% 的情況下都能正常工作,但以下測試說明了一個例子,其中上面的程式碼產生了相當令人驚訝的結果。

1
2
3
test('splitString emoji', () {
expect(splitString('abc👨‍👩‍👧‍👦abc abc abc', '👨‍👩‍👧‍👦'), ['abc', 'abc', 'abc', 'abc']);
});

以下是測試結果:

1
2
3
Expected: ['abc👨‍👩‍👧‍👦', 'abc', 'abc', 'abc']
Actual: ['abc👨‍👩‍', '‍👦', 'abc', 'abc', 'abc']
Which: was 'abc👨‍👩‍' instead of 'abc👨‍👩‍👧‍👦' at location [0]

那麼,為什麼當字串被分割時,👨‍👩‍👧‍👦 變成了兩個表情符號 👨‍👩?這是因為 👨‍👩‍👧‍👦 實際上由四個不同的表情符號組成:👨👩👧👦。當字串在 👧 上被分割時,“abc👨‍👩‍👧‍👦” 被分成了兩部分:“abc👨‍👩” 和 “‍👦”。

您可以透過使用 Characters 類別上的 split 方法來避免此問題,如下列程式碼所示:

1
2
3
List<String> splitString(String text, String emoji) {
return text.characters.split(emoji.characters).map((e) => e.toString()).toList();
}

場景 4:根據索引存取特定字元

在文字操作中,通常根據字串中的索引(即位置)存取特定字元。例如,以下程式碼片段顯示了一個函數,該函數從使用者在兩個單獨的文字欄位中輸入的名和姓中返回首字母縮寫:

1
2
3
String getInitials(String firstName, String lastName) {
return firstName[0] + lastName[0];
}

但是,正如我們在本文開頭所演示的那樣,在基於 UTF-16 的字串中使用索引可能會有風險。讓我們用以下測試案例驗證上述程式碼的正確性:

1
2
3
test('getInitials accent', () {
expect(getInitials('Élise', 'Boisson'), 'ÉB');
});

以下是測試結果:

1
2
3
Expected: ‘ÉB’
Actual: ‘EB’
Which: is different.

為什麼測試失敗了?這是因為字母 “É” 可能是 “E” 和重音符號的組合。您可以使用 characters 套件輕鬆避免此問題:

1
2
3
String getInitials(String firstName, String lastName) {
return firstName.characters.first.toString() + lastName.characters.first.toString();
}

練習:省略文字溢位

現在,這是一個挑戰。在此場景中,應用程式需要顯示訊息列表,每行顯示一條訊息。要求您檢查實作一個函數的程式碼,該函數在訊息長度超過給定字元限制時將文字溢位顯示為省略號。

1
2
3
4
5
6
String omitTextOverflow(String text, int limit) {
if (text.length &lt;= limit) {
return text;
}
return text.substring(0, limit - 3) + '...';
}

您能否提出一個測試來揭示此程式碼片段的潛在問題?您將如何使用 characters 套件重寫它?答案在本文末尾。

緩解措施和可能的長期解決方案

期望 Dart 使用者對上述陷阱保持高度警惕是不合理的。例如,在我們進行的一項實驗中,53.7% 的 Dart 使用者無法檢測到第一個場景(計算字元數)中說明的問題,即使他們在幾分鐘前收到了兩頁關於 characters 套件以及該套件旨在解決的問題的資訊。因此,我們正在採取兩階段方法來幫助開發人員為他們的文字操作需求選擇最合適的 API。

短期內,我們將在 Flutter 框架和 Dart 分析器中引入一組緩解措施,以使 characters 套件更容易在 Dart UI 程式設計中發現和調用。這涉及幾個步驟:

  1. 在 TextField Widget 的內部實作中使用 characters 套件。有關更多詳細資訊,請參閱 此 PR此設計文件
  2. 透過 Flutter 框架公開 characters 套件的 API。完成此操作後,Flutter 使用者將更有可能透過擴展方法 String.characters 發現 API,該方法將在對 String 執行自動完成時顯示。此工作的狀態在此議題中追蹤:https://github.com/flutter/flutter/issues/55593
  3. 更新 Flutter 框架的 API 文件和範例程式碼,以建議在適用時使用 Characters 類別,例如在 TextField.onChanged 的回調中。此工作在 https://github.com/flutter/flutter/issues/55598 中追蹤,相關詳細資訊在 此文件 中。
  4. 讓 Dart 分析器在自動完成用於處理使用者輸入文字的回調模板時建議將 String 物件轉換為 Characters 物件。例如,在使用者在 onChanged 上自動完成後,IDE 可以填寫以下程式碼片段中的所有內容。此工作在 https://github.com/dart-lang/sdk/issues/41677 中追蹤。
1
2
3
4
5
6
7
TextField(
onChanged: (text) {
final characters = text.characters;
// …
},
);

這些緩解措施可以提供幫助,但它們僅限於在 Flutter 專案上下文中執行的字串操作。我們需要在它們可用後仔細衡量它們的有效性。Dart 語言層面更完整的解決方案可能需要遷移至少一些現有程式碼,儘管一些選項(例如,靜態擴展類型可能會使重大變更易於管理。需要更多技術調查才能完全了解權衡取捨。

如何提供幫助

請幫助我們提高對如何使用 characters 套件修復字串問題的認識:

  • 在您自己的程式碼中尋找使用 String.lengthString.substring 的實例。如果字串可能源自使用者輸入,請嘗試使用 characters 套件重寫程式碼。
  • 與 Dart 社群中的其他人分享這篇文章。
  • 嘗試更新 StackOverflow 上關於 Dart 文字操作的 現有答案。如果已接受的答案忽略了 String API 的此限制,請提醒人們注意風險。
  • 對上面列出的 GitHub 議題發表評論,讓我們知道您的想法和意見。

現在,祝您編碼愉快 😉!

致謝

感謝 Kathy Walrath、Lasse Nielsen 和 Michael Thomson 審閱本文。我還要感謝參與我們使用者研究的開發人員。他們的參與幫助 Dart 和 Flutter 團隊更好地了解了處理 Dart String API 此限制的挑戰。


PS:以下是練習的解決方案:

1
2
3
4
5
6
7
8
9
10
String omitTextOverflow(String text, int limit) {
if (text.characters.length &lt;= limit) {
return text;
}
return text.characters.take(limit - 3).toString() + '...';
}

test("omitTextOverflow doesn't break emoji", () {
expect(omitTextOverflow("How are you doing today 😀", 20), "How are you doing...");
});


正確操作 Dart 字串 👉 最初發佈在 Dart 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。