雲端 Mac 上驗收 iOS 文字標示:UTF-16 與組合字元邊界

雲端 Mac 上驗收 iOS 文字標示:UTF-16 與組合字元邊界

雲端 Mac 上驗收 iOS 文字標示:UTF-16 與組合字元邊界

搜尋結果列表中的姓名,英文顯示正常,碰到帶重音符號的文字或膚色表情符號,醒目標示卻偏移了一截。問題通常不在字型,而在範圍的單位約定:伺服器提供的是位元組偏移量,介面接收的是 UTF-16 偏移量,業務邏輯卻又按 Swift 的字元數擷取文字。在雲端 Mac 上先執行一組純文字檢查,比反覆操作遠端桌面更容易找出問題。

這套方法適用於搜尋摘要、富文字標記和聊天內容選取範圍。只需要既有的 Xcode 命令列工具,不需要連線至外部服務;在 XcNode 的獨享實體節點上,也應將測試輸入與工具鏈紀錄一併保留。

先訂清楚偏移量的單位

Swift String.count 計算的是延伸字素叢集,也就是 Character;utf16.count 計算 UTF-16 碼元;utf8.count 計算位元組。三者不能互換。一個表情符號可能由基本圖形、膚色修飾符和零寬連接符組成,看起來只有一個字元,內部卻包含多個碼元。

Foundation 的 NSRange 用於字串介面時,通常以 UTF-16 為單位。不要把 text.count 當成有效範圍的上限,也不要直接用整數推進 String.Index。介面文件至少應約定單位、原文版本、空範圍的處理方式,以及找不到符合項目時的表示方法。

轉換成功只代表索引可以建立,不保證範圍兩端都落在完整的使用者可見字元邊界上。顯示醒目標示仍需要第二層驗證。

原文一旦修改,舊範圍就應失效。不要拿舊搜尋結果去標示經過替換、大小寫轉換或刪減的新字串。

用小腳本排除不完整的字元

將以下內容儲存為 highlight-check.swift。驗證時先處理不存在與超出範圍的情況,再轉換索引,最後檢查兩端是否位於 Character 邊界。以減法判斷長度,也能避免先計算起點加上長度時發生整數溢位。

import Foundation

func highlightRange(
    _ value: NSRange,
    in text: String
) -> Range<String.Index>? {
    let total = text.utf16.count
    guard value.location != NSNotFound,
          value.location >= 0,
          value.length >= 0,
          value.location <= total,
          value.length <= total - value.location,
          let range = Range(value, in: text) else {
        return nil
    }

    var boundaries = Set(text.indices)
    boundaries.insert(text.endIndex)
    guard boundaries.contains(range.lowerBound),
          boundaries.contains(range.upperBound) else {
        return nil
    }
    return range
}

let text = "A👩🏽‍💻e\u{301}Z"
let emoji = (text as NSString).range(of: "👩🏽‍💻")
precondition(highlightRange(emoji, in: text) != nil)
precondition(highlightRange(NSRange(location: 1, length: 1),
                           in: text) == nil)

let accent = "e\u{301}"
precondition(highlightRange(NSRange(location: 0, length: 1),
                           in: accent) == nil)
precondition(highlightRange(NSRange(location: 0, length: 2),
                           in: accent) != nil)
precondition(highlightRange(NSRange(location: NSNotFound, length: 0),
                           in: text) == nil)
precondition(highlightRange(NSRange(location: text.utf16.count,
                                   length: 1),
                           in: text) == nil)

執行 xcrun swift highlight-check.swift,結束碼為零表示這些斷言全部通過;失敗時會中止執行。不要使用會停用檢查的最佳化選項。納入正式測試目標後,應將這些斷言改成附有樣本編號的測試斷言,方便找出失敗的輸入。

這裡允許位於字元邊界上的空範圍,因為編輯器的插入點可能需要它。只用於搜尋醒目標示的呼叫端,應另外拒絕零長度的符合項目,避免循環搜尋無法繼續往前。

搜尋時保留原文座標

為了忽略重音符號而先建立簡化副本,再將副本的偏移量套回原文,是常見的錯誤。例如,預組合的帶重音字元與字母加上組合重音符號,可以表示相同的文字,但 UTF-16 長度未必相同。

如果產品確實要求忽略大小寫或重音符號,可以直接在原文中搜尋,讓回傳範圍仍對應原始字串:

let source = "Cafe\u{301}"
let match = source.range(
    of: "cafe",
    options: [.caseInsensitive, .diacriticInsensitive],
    locale: Locale(identifier: "en_US_POSIX")
)
guard let match else {
    fatalError("Expected a search match")
}
let nsRange = NSRange(match, in: source)
precondition(highlightRange(nsRange, in: source) != nil)
precondition(String(source[match]) == source)

固定地區設定是為了讓這個樣本的結果可重現,並不是所有語言搜尋都適用的統一答案。姓名搜尋是否忽略重音符號、土耳其語的大小寫如何處理,都應由產品定義,再分別加入樣本。需要斷詞或轉寫時,應維護明確的原文對照關係,不能靠長度相減來猜測位置。

將失敗樣本納入回歸測試關卡

至少涵蓋一般漢字、代理對、組合重音符號、膚色表情符號、連接式表情符號和旗幟序列。每一類都要同時檢查完整命中與從內部截斷的情況;再補上開頭與結尾的空範圍、超出末尾,以及找不到符合項目的結果。

對每個有效範圍進行往返檢查:轉成 Swift 範圍後,再用 NSRange(range, in: text) 轉回,結果必須一致。也要斷言實際選取的文字,避免漏掉「範圍有效,卻命中錯誤位置」的情況。

處理長文字時,不宜為每個符合項目反覆掃描所有邊界。可以針對同一份不可變的原文建立一次邊界集合,供多個符合項目共用;文字更新後再重新產生。快取不能跨不同字串重複使用。

UI 驗收只補上顯示層的證據

純文字測試關卡通過後,再於模擬器開啟固定的搜尋樣本,檢查醒目標示沒有切開重音符號、表情符號序列沒有出現局部著色,而且換行後的標記仍對應原文。視覺檢查用來處理繪製問題,不能取代範圍斷言。

非同步搜尋回傳時,先核對請求所對應的原文版本,再套用醒目標示。輸入欄位已經改變,就應捨棄舊結果。也應區分搜尋範圍與最終顯示範圍:如果顯示層必須擴展到完整字元,就保留原始符合範圍供業務邏輯判斷,擴展後的範圍只負責繪製。

最終驗收標準不是「沒有當機」,而是單位明確、範圍有效、邊界完整、命中文字正確。將實際出錯的字串加入固定樣本,下次調整搜尋演算法時,就能立即知道哪些行為發生了變化。

常見問題

Range 轉換成功就代表可以安全標示嗎?

不代表。還要用 text.indices 與 endIndex 確認範圍兩端都落在完整 Character 的邊界。

可以直接沿用移除音標後的搜尋位移嗎?

不可以直接沿用。轉換可能改變 UTF-16 長度;應在原文使用約定的搜尋選項,或維護明確的原文索引對照。

為開發工作租用專屬雲端 Mac

採用 Apple Silicon 實體主機,並非虛擬機。提供日租、週租、月租及季租,機型與價格公開透明;實際可租狀態請以控制台即時資訊為準。

立即租用雲端 Mac 查看機型與租期