Unicode-Grenzen für iOS-Textmarkierungen auf dem Cloud-Mac testen
Bei Namen in einer Suchergebnisliste funktioniert die Hervorhebung mit englischem Text, verrutscht aber bei Buchstaben mit diakritischen Zeichen oder Emoji mit Hauttonmodifikatoren. Meist liegt das nicht an der Schriftart, sondern an uneinheitlichen Vereinbarungen über Textbereiche: Der Server liefert Byte-Offsets, die Oberfläche erwartet UTF-16-Offsets, und die Geschäftslogik schneidet den Text anhand der Swift-Zeichenanzahl zu. Auf dem Cloud-Mac lässt sich die Ursache mit einigen reinen Textprüfungen leichter eingrenzen als durch wiederholtes Klicken auf dem Remote-Desktop.
Das Verfahren eignet sich für Suchauszüge, Rich-Text-Markierungen und Textauswahlen in Chats. Dafür reichen die vorhandenen Xcode-Kommandozeilenwerkzeuge; externe Netzwerkdienste sind nicht erforderlich. Auch auf einem dedizierten physischen Knoten von XcNode sollten die Testeingaben zusammen mit den Angaben zur Toolchain aufbewahrt werden.
Zuerst die Einheit der Offsets festlegen
Swift String.count zählt erweiterte Graphemcluster, also Character-Werte; utf16.count zählt UTF-16-Codeeinheiten und utf8.count Bytes. Diese Größen sind nicht austauschbar. Ein Emoji kann aus einem Basissymbol, einem Hauttonmodifikator und einem Zero Width Joiner bestehen. Es erscheint als ein einziges Zeichen, enthält intern aber mehrere Codeeinheiten.
Bei String-Schnittstellen verwendet Foundations NSRange in der Regel UTF-16-Einheiten. text.count ist daher keine geeignete Obergrenze für einen gültigen Bereich. Ebenso sollte ein String.Index nicht direkt um einen Integer-Wert weitergesetzt werden. Die Schnittstellendokumentation muss mindestens die Einheit, die Version des Originaltexts, den Umgang mit leeren Bereichen und die Darstellung eines nicht gefundenen Treffers festlegen.
Eine erfolgreiche Konvertierung bedeutet nur, dass sich die Indizes erzeugen lassen. Sie garantiert nicht, dass beide Bereichsgrenzen an den Grenzen vollständiger, für den Nutzer sichtbarer Zeichen liegen. Für die sichtbare Hervorhebung ist eine zweite Prüfung nötig.
Sobald sich der Originaltext ändert, müssen alte Bereiche ungültig werden. Alte Suchergebnisse dürfen nicht zur Markierung eines neuen Strings verwendet werden, der durch Ersetzungen, Änderungen der Groß- und Kleinschreibung oder Kürzungen entstanden ist.
Mit einem kleinen Skript unvollständige Zeichen abweisen
Den folgenden Inhalt als highlight-check.swift speichern. Die Prüfung behandelt zunächst nicht gefundene und außerhalb des Texts liegende Bereiche, konvertiert dann die Indizes und kontrolliert zuletzt, ob beide Enden auf Character-Grenzen liegen. Die Längenprüfung per Subtraktion vermeidet außerdem einen Integer-Überlauf, der beim vorherigen Addieren von Startposition und Länge entstehen könnte.
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)
Das Skript mit xcrun swift highlight-check.swift ausführen. Ein Exit-Code von null bedeutet, dass diese Assertions bestanden wurden; bei einem Fehler bricht die Ausführung ab. Keine Optimierungsoptionen verwenden, die die Prüfungen deaktivieren. Nach der Übernahme in das reguläre Test-Target sollten die Assertions durch Test-Assertions mit Fallnummern ersetzt werden, damit sich fehlerhafte Eingaben leichter zuordnen lassen.
Leere Bereiche an Zeichengrenzen sind hier zulässig, weil ein Editor sie für Einfügepositionen benötigen kann. Aufrufer, die ausschließlich Suchtreffer hervorheben, sollten Treffer mit der Länge null zusätzlich abweisen. Sonst kann eine wiederholte Suche an derselben Stelle hängen bleiben.
Bei der Suche die Koordinaten des Originaltexts erhalten
Ein häufiger Fehler besteht darin, zum Ignorieren diakritischer Zeichen zunächst eine vereinfachte Kopie zu erstellen und deren Offsets anschließend auf den Originaltext zu übertragen. Ein vorkomponiertes Zeichen mit Akzent und ein Buchstabe mit kombinierendem Akzent können beispielsweise denselben Text darstellen, müssen aber nicht dieselbe UTF-16-Länge haben.
Wenn das Produkt tatsächlich eine Suche unabhängig von Groß- und Kleinschreibung oder diakritischen Zeichen verlangt, kann direkt im Originaltext gesucht werden. Der zurückgegebene Bereich bleibt dann dem ursprünglichen String zugeordnet:
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)
Die feste Locale macht diesen Testfall reproduzierbar; sie ist keine allgemeingültige Lösung für die Suche in allen Sprachen. Ob bei der Namenssuche diakritische Zeichen ignoriert werden und wie die türkische Groß- und Kleinschreibung behandelt wird, muss das Produkt festlegen. Dafür sind jeweils eigene Testfälle nötig. Wenn eine Wortsegmentierung oder Transliteration erforderlich ist, muss eine explizite Zuordnung zum Originaltext gepflegt werden. Positionen lassen sich nicht zuverlässig aus Längendifferenzen ableiten.
Fehlerfälle als verbindliche Regressionstests übernehmen
Die Tests sollten mindestens gewöhnliche chinesische Schriftzeichen, Surrogatpaare, kombinierende diakritische Zeichen, Emoji mit Hauttonmodifikatoren, verbundene Emoji und Flaggensequenzen abdecken. Für jede Kategorie sind sowohl vollständige Treffer als auch Bereiche zu prüfen, die innerhalb eines Zeichens abgeschnitten werden. Hinzu kommen leere Bereiche am Anfang und Ende, Bereiche über das Textende hinaus sowie nicht gefundene Ergebnisse.
Für jeden gültigen Bereich einen Roundtrip prüfen: Nach der Konvertierung in einen Swift-Bereich muss die Rückkonvertierung mit NSRange(range, in: text) denselben Wert ergeben. Außerdem muss eine Assertion den tatsächlich ausgewählten Text prüfen. So bleiben Fälle nicht unbemerkt, in denen der Bereich zwar gültig ist, aber auf die falsche Stelle zeigt.
Bei langen Texten sollten nicht für jeden Treffer erneut sämtliche Grenzen durchlaufen werden. Für denselben unveränderlichen Originaltext lässt sich die Menge der Grenzen einmal aufbauen und für mehrere Treffer gemeinsam nutzen. Nach einer Textänderung muss sie neu erzeugt werden. Ein solcher Cache darf nicht für unterschiedliche Strings wiederverwendet werden.
Die UI-Abnahme ergänzt nur den Nachweis für die Darstellung
Erst wenn die reinen Textprüfungen bestanden sind, die festen Suchtestfälle im Simulator öffnen. Dabei kontrollieren, dass die Hervorhebung keine diakritischen Zeichen abtrennt, Emoji-Sequenzen nicht nur teilweise eingefärbt werden und Markierungen auch nach einem Zeilenumbruch dem Originaltext entsprechen. Die Sichtprüfung dient der Kontrolle des Renderings; sie ersetzt keine Bereichs-Assertions.
Wenn eine asynchrone Suche ein Ergebnis liefert, vor dem Anwenden der Hervorhebung zunächst die zur Anfrage gehörende Version des Originaltexts prüfen. Hat sich das Eingabefeld bereits geändert, ist das alte Ergebnis zu verwerfen. Außerdem sollten Suchbereich und endgültiger Darstellungsbereich getrennt bleiben: Muss die Darstellung auf vollständige Zeichen erweitert werden, bleibt der ursprüngliche Treffer für Entscheidungen der Geschäftslogik erhalten. Der erweiterte Bereich dient ausschließlich dem Zeichnen.
Das endgültige Abnahmekriterium lautet nicht „kein Absturz“, sondern: eindeutige Einheiten, gültige Bereiche, vollständige Zeichengrenzen und korrekt getroffener Text. Werden tatsächlich fehlgeschlagene Strings in die festen Testfälle aufgenommen, zeigt die nächste Änderung am Suchalgorithmus sofort, welche Verhaltensweisen sich verändert haben.
Häufig gestellte Fragen
Reicht eine erfolgreiche Umwandlung von NSRange?
Nein. Prüfen Sie zusätzlich mit text.indices und endIndex, ob beide Grenzen an vollständigen Character-Grenzen liegen.
Darf man Offsets einer Kopie ohne Akzente übernehmen?
Nicht direkt. Die UTF-16-Länge kann sich ändern. Suchen Sie im Originaltext mit den festgelegten Optionen oder führen Sie eine explizite Indexzuordnung.
Der passende Cloud-Mac für Ihre Entwicklungsprojekte
Physischer Apple-Silicon-Knoten statt VM. Mieten Sie tage-, wochen-, monats- oder quartalsweise. Modelle und Preise sind transparent einsehbar; die aktuelle Verfügbarkeit wird im Portal in Echtzeit angezeigt.