[Swift]文字列の長さを正しく数えられずにHot-Fixした話
概要
Swiftで既存のiOSアプリへの新機能開発で、文字列の長さの扱いを間違えて実装してしまった。
リリース後に、"Index Out Of Bounds" のクラッシュログが報告されていて、バグに気付いた。
影響の大きさから、Hot-Fixすることになってしまった。
反省として、文字列の長さの数え方は何通りか考えられるので、どれがふさわしいかを考えてから使うべきであると学んだ。
やりたかったこと
機能の要件として、以下のものを実装したい。
- Userが文字列を入力できるTextViewがある
- TextViewの下にButtonを表示する。
- UserはButtonを押すことで、TextViewのCursorより前方にある文字列を取得できる。
- Buttonを押すことによって取得した文字列を、Button下のLabelに表示する。
完成した実装
Mainの画面となるViewControllerは以下のような感じ。
class ViewController: UIViewController {
// UI Elements
let textView = UITextView()
let button = UIButton(type: .roundedRect)
let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
// UIのLayout Constraintや見た目などをよしなに調整する。
setupLayout()
button.addTarget(self, action: #selector(tap), for: .touchUpInside)
}
/*
1. Cursorより前方の文字列を取得し、
2. labelに渡す
*/
@objc func tap() {
let textBeforeCursor = textView.getStringBeforeCursor() ?? "nil"
label.text = "Result: \(result)"
}
}
Stringの部分文字列を取得しやすいように、StringProtocolに subscript
を実装
extension StringProtocol {
public subscript(bounds: CountableRange<Int>) -> String {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return String(self[start..<end])
}
}
最後に今回の要件のコアとなる、TextViewのCursorより前方にある文字列を取得するロジックを実装する。
extension UITextView {
func getStringBeforeCursor() -> String? {
// Cursorの位置を取得
guard let selectedRange = self.selectedRange else { ruetrn nil }
// Cursor直前の文字のPositionを取得
let cursorStartPosition = self.offset(from: self.beginningOfDocument, to: selectedRange.start)
guard cursorStartPosition > 0 else { return nil }
// [0...cursorStartPosition)までの部分文字列を取得する
let textBeforeCursor = text[0 ..< cursorStartPosition]
return textBeforeCursor
}
動かしてみると
"ABCDE"を入力して、"C"と"D"の間にCursorがある場合
Buttonを押すと、"Result: ABC"と表示される。
ここまでは要件通り、正しく動作してそう。
リリースしてみると、大量の"Index Out Of Range"のクラッシュログ😈
😈?
Emojiが入力されているときに、Buttonを押すと
上記のStringProtocolのsubscript
で Fatal Error が発生する。
現象
UITextViewのCursor前方の文字列を取得するためにgetStringBeforeCursor() -> String?
を実装した。
getStringBeforeCursor
では、以下の2つのことを行っていた。
- cursorの直前の文字の位置を求める (
cursorStartPosition
) - 入力された文字列の
[0...cursorStartPosition)
までの部分文字列を取得する。
1で計算したcursorStartPosition
を使用して、入力された文字列にIndexアクセスしたときに、"Index Out of Bounds" が発生する。
多くの場合は想定通り動作するが、絵文字が入力されている場合、クラッシュが発生する。
原因
文字は複数のUnicode Characterで形成されている場合がある(e.g. Emojiなど)
-
String().count
は、文字列の 見た目上の長さ を返す
-
String().utf16.count
は、文字列の uft16表記での長さ を返す
UITextView().offset(from: to:)の挙動
UITextView().offset(from: to:)
のドキュメントは以下の記述がある。
Returns the number of UTF-16 Characters
実装したString().subscript
String()の部分文字列を、"見た目上の長さ"を元にしたIndexで取得する。
getStringBeforeCursor()
は
上記の3つを踏まえると、入力された文字列の 見た目上の長さ と utf-16表現での長さが同じとき
- 正常に動作する。(e.g. "ABCDE")
入力された文字列の 見た目上の長さ と utf-16表現での長さが違うとき
- Index out of Bounds(e.g. "😈")
- 入力が"😈"のとき
-
cursorStartPosition
は utf16表現での長さですので、2 - 一方、見た目上の長さは、1
- 長さ1の配列から、先頭2文字を取得しようとするので、Index Out Of Boundsとなる。
-
正しい実装
func getStringBeforeCursor_OK() -> String? {
guard let selectedRange = self.selectedRange else { ruetrn nil }
guard let range = self.textRange(from: self.beginningOfDocument, to: selectedRange.start) else { return nil }
let previousCharacter = self.text(in: range)
return previousCharacter
}
文字列の範囲を"UITextRange"で扱っている。
上記のような扱い方の不整合が発生しない。
反省
- 普段あまり使わないAPIを使用するときは、ドキュメントに目を通す。入力や出力が何なのかを確認する。
- 特に、文字列の長さを扱うときは、"何をもとにして計算した長さ"なのかを意識する。
Discussion