😬

[Swift]文字列の長さを正しく数えられずにHot-Fixした話

2024/06/02に公開

概要

Swiftで既存のiOSアプリへの新機能開発で、文字列の長さの扱いを間違えて実装してしまった。
リリース後に、"Index Out Of Bounds" のクラッシュログが報告されていて、バグに気付いた。
影響の大きさから、Hot-Fixすることになってしまった。
反省として、文字列の長さの数え方は何通りか考えられるので、どれがふさわしいかを考えてから使うべきであると学んだ。

やりたかったこと

機能の要件として、以下のものを実装したい。

  • Userが文字列を入力できるTextViewがある
  • TextViewの下にButtonを表示する。
  • UserはButtonを押すことで、TextViewのCursorより前方にある文字列を取得できる。
  • Buttonを押すことによって取得した文字列を、Button下のLabelに表示する。

完成した実装

Mainの画面となるViewControllerは以下のような感じ。

ViewController.swift

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を実装

StringProtocol+Extension
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より前方にある文字列を取得するロジックを実装する。

UITextView+Extension
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のsubscriptFatal Error が発生する。

現象

UITextViewのCursor前方の文字列を取得するためにgetStringBeforeCursor() -> String?を実装した。
getStringBeforeCursorでは、以下の2つのことを行っていた。

  1. cursorの直前の文字の位置を求める (cursorStartPosition)
  2. 入力された文字列の[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で取得する。

上記の3つを踏まえると、getStringBeforeCursor()

入力された文字列の 見た目上の長さutf-16表現での長さが同じとき
  • 正常に動作する。(e.g. "ABCDE")
入力された文字列の 見た目上の長さutf-16表現での長さが違うとき
  • Index out of Bounds(e.g. "😈")
  • 入力が"😈"のとき
    • cursorStartPositionutf16表現での長さですので、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を使用するときは、ドキュメントに目を通す。入力や出力が何なのかを確認する。
  • 特に、文字列の長さを扱うときは、"何をもとにして計算した長さ"なのかを意識する。

Ref

Discussion