ObjecTips

Swift & Objective-C で iOS とか macOS とか

AVSpeechSynthesizer の delegate メソッド willSpeakRangeOfSpeechString でクラッシュする危険性

AVSpeechSynthesizer の delegate メソッド speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:) は音声読み上げの際にこれから読み上げる箇所を NSRange で知らせてくれる。
このメソッドを使って読み上げテキスト全体のうち今どこが読まれているのかを知る事ができ、ハイライト表示などにも利用する事ができる。

NSRangelocation にはテキストの開始位置、length には長さが入る。
読み上げ中のテキスト全体は AVSpeechUtterance から speechString で取得可能。これから読み上げる部分テキスト取得の具体的な実装は以下

NSRange をそのまま使うパターン、Swift.StringNSString にキャストする

    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        let substring = (utterance.speechString as NSString).substring(with: characterRange)
        print(substring)
    }

もしくは NSRangeRange<String.Index> に変換して Swift.String をそのまま使うパターン

    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        if let range = Range<String.Index>(characterRange, in: utterance.speechString) {
            let substring = utterance.speechString[range]
            print(substring)
        }
    }

クラッシュの危険性

当該の delegate メソッドは日本語での読み上げではかなり不具合があり、短い文章ならともかく通常の文章では正しい読み上げ位置をレポートしてくれる事はほぼ無く、読み上げ位置が間違っている、スキップする、停止するといった問題がある。*1
また特定の日本語テキストの並び、組み合わせによっては NSRange に不正な値が入ってきてしまいその値を使う事でアプリがクラッシュしてしまうケースがある事が最近分かった。ちなみに問題が確認された日本語テキストは iOS 13.2 では不正な値が入っている現象が解消されており*2、バージョンを遡って確認したところ iOS 12 から iOS 13.1.3 で問題が確認された。

不正な値は NSRangelength-1 になっている。
本来 NSRange は適切な範囲を取得できない場合は locationNSNotFoundlength には 0 が入るはず。(これについてドキュメントソースはみつからず。NSString のAPIの場合は明示的に書かれていたりする https://developer.apple.com/documentation/foundation/nsstring/1416849-range

NSRange のドキュメントによれば

NSRange - Foundation | Apple Developer Documentation

location

The start index (0 is the first, as in C arrays).

length

The number of items in the range (can be 0).

と書かれており、length0 を取り得るものの、マイナス値については言及が無く NSNotFound の様な定数も存在していない。
この length-1 が入った NSRange をそのまま先の substring 取得の実装で使用すると
NSRangeException が発生してアプリはクラッシュしてしまう。

不正な NSRange の原因

NSRangeException のメッセージには Range {7, 18446744073709551615} out of bounds; と書かれていた。
NSRangelocation length 共に Int 型であり、最大値は Int.max = NSInteger.max9223372036854775807
NSNotFound もドキュメントによれば同じ値になる。

NSNotFound is now formally defined as NSIntegerMax

NSNotFound - Foundation | Apple Developer Documentation

しかし実際に Exception で表示されている値は 18446744073709551615 これは NSUIntegerMax の値になっている。
つまり NSRangeInt 型に UInt.max が入ってきてしまっている。
Swift なら型の範囲外の値が入ってきた時点で Exception が起きて処理が止まるはず。なぜこういう事態が起きているのか。

NSRange の Swift と Objective-C でのヘッダでの定義を見てみるとヒントがあった。

Swift

public struct _NSRange {

    public var location: Int

    public var length: Int

    public init()

    public init(location: Int, length: Int)
}

Objective-C

typedef struct _NSRange {
    NSUInteger location;
    NSUInteger length;
} NSRange;

これまでの Objective-C の世界で NSUInteger で動いていたものを Swift の世界では Int で扱っている。型が変わってしまっている!なぜこんな事に。

NSRange のドキュメントを見ると

length
The number of items in the range (can be 0). For type compatibility with the rest of the system, LONG_MAX is the maximum value you should use for length.

location
The start index (0 is the first, as in C arrays). For type compatibility with the rest of the system, LONG_MAX is the maximum value you should use for location.

NSRange - Foundation | Apple Developer Documentation

この様に最大値は LONG_MAX == 9223372036854775807
つまり 64bit の IntergerMax である(にすべき)と記述されている。
それ以上の数値を扱う想定がされていない。そこで Swift への移行を契機に(定義のシンプルさも考慮して)Int 型に変更になったのだと思われる。

NSRangelocation length の Objective-C Swift それぞれの型と想定される値の取り得る範囲は以下になる。

min max
Objective-C Type 0 18446744073709551615
Usage 0 9223372036854775807
Swift Type -9223372036854775808 9223372036854775807

9223372036854775807 == NSNotFound == Int64.max
18446744073709551615 == UInt64.max

回り道になったけど本題に戻って、問題の原因は NSRange仕様上の最大値が Int64.max であるにも関わらず型としての最大値 UInt64.max が指定されてしまう事があるという点になる。
おそらく AVFoundation の Objective-C 側の処理で何かしらの例外が発生した際に NSRangeNSUInteger length;-1 を入れてオーバーフローで NSUIntegerMax になってしまったか、直接 NSUIntegerMax を入れてしまったか、そんなところだろうと推測される。*3

対策

NSRange の取り得る範囲をチェックして有効な時のみ処理を行う様にする。*4

型としての値の範囲チェックのついでに NSNotFound でない事もチェックしておいてもいいかも知れない。
簡便に以下の様に NSNotFound の場合も処理を行わない様に実装する事もできる。

    if 0 <= characterRange.length, characterRange.length < NSNotFound {
        let substring = (utterance.speechString as NSString).substring(with: characterRange)
        print(substring)
    }


他の実装方法として NSRange の有効性をチェックする extension を書く事もできる。個人的には純粋な値の有効性のチェックは公式ドキュメントの仕様ベースで実装し、 NSNotFound かどうかのチェックは extension には含めない様にして extension の外でエラー処理を分けた方がいい気がする。

extension の実装

extension NSRange {
    var isValid: Bool {
        if 0 <= location, location <= LONG_MAX,
            0 <= length, length <= LONG_MAX {
            return true
        }
        return false
    }
}

extension の利用

        guard characterRange.isValid else {
            print("invalid range")
            return
        }
        guard characterRange.location != NSNotFound, characterRange.length != NSNotFound else {
            print("location or length are not found")
            return
        }
        let substring = (utterance.speechString as NSString).substring(with: characterRange)
        print(substring)

以上

*1:AVFoundation API だけでなくシステムの読み上げ箇所ハイライト機能(アクセシビリティ>読み上げコンテンツ>内容を強調表示)も同じ問題を抱えている

*2:とは言っても正しい読み上げ位置のレポートはされないまま

*3:もっと下の階層のC関数でエラー -1 を返してそれがそのまま Objective-C の階層まで上がってきた説もある

*4:API側の渡す値が常に正しいのであればこの様な処理はいらないけど、現実にはそうではないので止む無くガード処理という感じ