ObjecTips

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

Vision framework VNRecognizeTextRequest でのテキスト認識(OCR)の対応言語 iOS 13, iOS 14

iOS 13でOCRに使えるテキスト認識のAPIが登場。
使えるRevisionは1。iOS 14でRevision 2が登場した。

@available(iOS 13.0, *)
public let VNRecognizeTextRequestRevision1: Int

@available(iOS 14.0, *)
public let VNRecognizeTextRequestRevision2: Int

iOS 13

検証環境 Xcode 12.0 beta (12A6159) iPadOS 14 (17A5301v) 1st beta

以下のコードでサポート言語を確認できる

print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .fast, revision: VNRecognizeTextRequestRevision1))
print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .accurate, revision: VNRecognizeTextRequestRevision1))
["en-US"]
["en-US"]

iOS 13は英語のみの対応

iOS 14

print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .fast, revision: VNRecognizeTextRequestRevision1))
print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .accurate, revision: VNRecognizeTextRequestRevision1))
print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .fast, revision: VNRecognizeTextRequestRevision2))
print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .accurate, revision: VNRecognizeTextRequestRevision2))
["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR"]
["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant"]

iOS 14では英語、フランス語、イタリア語、ドイツ語、スペイン語、ポルトガル語(ブラジルポルトガル語)、中国語(繁体字と簡体字)の対応が追加された。
中国語(繁体字と簡体字)は処理時間のかかる accurate モードのみでサポートされる。
今回も日本語の対応は含まれていない。
中国語以外で対応されたのはアルファベット文字ベースのみ。日本語に限らずアラビア語のアラビア文字やロシア語のキリル文字などアルファベット文字と字形が大きく異なるものはまだ対応は難しいのかも知れない。*1
とりあえず出たばかりのiOS 14のファーストβで正式リリースまでに変更になる可能性もあるので引き続き注視していく。*2

*1:技術的なものかリソース配分の問題かは分からないけど

*2:日本語対応して欲しい人はFeedback Assistantで報告しよう

ヒラギノフォントが切れる問題 SwiftUI編

検証環境 Xcode 11.4.1

iOS+ヒラギノ+UILabel とか UIButton でググると過去の UIKit での問題が参照できます。
この問題は SwiftUI でも発生します。

まずサンプルとしてヒラギノ角ゴのW3を指定して Text を作成。
(デバッグのため青色の枠線も表示)

Japanese font without Japanese character causes th ...

f:id:Koze:20200509011329p:plain:w536

上の日本語を含まない状態だとpとyの下が切れている。
下の日本語を含んだ状態だと切れずに表示されている。


試しに frame を設定して Text の表示領域を大きくしてみる。*1

    var body: some View {
        VStack(spacing: 10) {
            Text("Copy")
                .font(font)
                .frame(height: 60)
                .border(borderColor)

            Text("Copy")
                .font(font)
                .border(borderColor)
                .frame(height: 60)
                .border(borderColor)
        }
    }

f:id:Koze:20200509013020p:plain:w530

表示領域は大きくなっても文字は切れたまま。


次は TextbaselineOffset を試してみる。*2

    let font = Font.custom("HiraginoSans-W3", size: 50)
    let uiFont = UIFont(name: "HiraginoSans-W3", size: 50)!
    let borderColor = Color(.systemBlue)

    var body: some View {
        HStack(spacing: 10) {
            Text("Copy")
                .font(font)
                .border(borderColor)
            
            Text("Copy")
                .font(font)
                .baselineOffset(-uiFont.descender)
                .border(borderColor)
        }
    }

f:id:Koze:20200509015209p:plain:w520

文字が切れなくなった。


SwiftUI の Font からはフォント情報の descender を取得する事ができないため別途 UIFont を使っている。これを CTFont を使う様にリファクタリング。
UIFontdescender は正の値が入っているが CTFontdescender は負の値が入っているので baselineOffset で使う際の符号も修正している。

    let ctFont = CTFontCreateWithName("HiraginoSans-W3" as CFString, 50, nil)
    var ctFontDescender: CGFloat {
        CTFontGetDescent(ctFont)
    }
    let borderColor = Color(.systemBlue)

    var body: some View {
        HStack(spacing: 10) {
            Text("Copy")
                .font(.init(ctFont))
                .border(borderColor)
            
            Text("Copy")
                .font(.init(ctFont))
                .baselineOffset(ctFontDescender)
                .border(borderColor)
        }
    }

f:id:Koze:20200509020338p:plain:w508

見た目の結果は1つ前の実装と同じ。
でも今回の実装の場合 Text の箇所で Font を都度 CTFont から init するため、表示箇所が多い場合はFontUIFont を1つずつ作成する前回の実装の方が動作としては効率的な気もする。
コードの構造的には今回の CTFont を使った実装の方が整頓されていると思う。


現在の実装では baselineOffset で調整した分テキスト全体が上に移動するため、表示文字や周りのUIとの組み合わせによっては見た目が気になってくる。

f:id:Koze:20200509021829p:plain:w520
(↑文字全体が上にシフトしているのが気になる。日本語の場合特に。)

SwiftUI には便利な offsetpadding が存在するので、これらで baselineOffset による移動を再調整してやる。

offset

                Text("Copy")
                    .font(.init(ctFont))
                    .baselineOffset(ctFontDescender)
                    .border(Color(.systemRed))
                    .offset(y: ctFontDescender / 2)
                    .border(borderColor)

f:id:Koze:20200509070053p:plain:w498

padding

                Text("Copy")
                    .font(.init(ctFont))
                    .baselineOffset(ctFontDescender)
                    .border(Color(.systemRed))
                    .padding(.top, ctFontDescender)
                    .border(borderColor)

f:id:Koze:20200509070119p:plain:w500

これで見た目の問題も解消された。


最後にお好みでExtension化。
毎回の font baselineOffset offset or padding の操作を Extension にしてまとめてやることもできる。*3

extension Text {
    public func ctFont(_ ctFont: CTFont) -> some View {
        let descent = CTFontGetDescent(ctFont)
        return self.font(.init(ctFont))
            .baselineOffset(descent)
            .offset(y: descent / 2)
//            .padding(.top, ctFontDescender)
    }
}

この Extension の中で条件分岐して日本語フォント(もしくは問題のある特定のフォント)の場合のみ処理を入れて他のフォント利用時にはレイアウトに影響を与えない様にする事も検討できる。

extension CTFont {
    
    // quick example
    var isJapaneseFont1: Bool {
        let encoding = CTFontGetStringEncoding(self)
        return encoding == CFStringEncodings.macJapanese.rawValue
    }
    
    // quick example
    var isJapaneseFont2: Bool {
        let languages = CTFontCopySupportedLanguages(self) as? [String]
        return languages?.contains("ja") ?? false
    }
}

上記の日本語フォントの判定実装はちゃんと調査はしてないので取り急ぎの例として。

最終実装

Japanese font without Japanese character causes th ...

f:id:Koze:20200509070543p:plain:w498

*1:UIKit だと frame を大きく取る事で問題を解決できる。

*2:UIKit だと NSAttributedString の baselineOffset を使って問題を解決できる。

*3:SwiftUI のビルトインの Text Extension の実装を見ていると返り値で Text ではなくて some View を返してやるのはイレギュラーな感じはする。

既存プロジェクトで SwiftUI のプレビュー機能を使う Using SwiftUI preview in existing project

WWDC 2019 Session 233 Mastering Xcode Previews
https://developer.apple.com/videos/play/wwdc2019/233/
を見ていて、あれ、これもしかして Deployement Targe iOS 13以降のプロジェクトじゃなくても SwiftUI のプレビュー表示は使えるんじゃない?と思って試してみたらできたので共有。

iOS 13より前をサポートしている既存のプロジェクトで Xcode の New > File... で SwiftUI View を選択して作成。

f:id:Koze:20200305225313p:plain

ここでは例として TableViewCellPreview.swift を作成。
作成したファイルを開くとまず available in iOS 13 or newer のビルドエラーが起きるのでまず Xcode の自動Fixに従って @available(iOS 13.0, *) を設定をしてエラーを修正。

f:id:Koze:20200305225359p:plain

f:id:Koze:20200305225513p:plain

するとひとまずプレビューが表示される。

f:id:Koze:20200305225952p:plain

テンプレートで作成されている struct TableViewCellPreview: View は SwiftUI によるView実装で、既存クラスのプレビューだけなら不要なので削除。
struct TableViewCellPreview_Previews: PreviewProvider の名前もくどいのでstruct TableViewCellPreview: PreviewProvider に変更(お好みで)
前者の TableViewCellPreview を削除した事によりまたエラーが起きるので static var previews: some View の返り値は一旦適当な SwiftUI View にしておく。(例では Text("Text")

f:id:Koze:20200305230605p:plain

んで、以下のセッションで紹介されている UIView UIViewController を SwiftUI で使用する実装方法に沿って実装する。
WWDC 2019 Session 233 Mastering Xcode Previews
https://developer.apple.com/videos/play/wwdc2019/233/
WWDC 2019 Session 231 Integrating SwiftUI
https://developer.apple.com/videos/play/wwdc2019/231

実装例は以下
今回プレビューしたい TableViewCell は xib でレイアウトを作成しているので makeUIView のところで UINib からインスタンス化している。updateUIView は今回は空でOK。

Using SwiftUI preview with UIView

既存プロジェクトの TableViewCell の実装内容は以下
UILabel にはそれぞれ Title1, Subhead, Body のフォントを設定して、Automatically Adjusts Font をオンにして Dynamic Type に対応させている。
そして標準の UITableViewCell と同じ様に Dynamic Type の文字が Accessibility 対応の文字の大きさになっている(ContentSizeCategory.extraExtraExtraLarge より大きい)場合はセルのラベルを縦レイアウトにするという実装をしている。

f:id:Koze:20200305234153p:plain

Accessible TableViewCell like system UITableViewCe ...

*1

TableViewCellPreview によるプレビューをキャンバスに表示した結果は以下になる。

f:id:Koze:20200306042326p:plain

期待通りの結果!
実機 or Simulator での実行や Environment Overrides、デバイス設定の変更などせず複数の状況を一度にプレビューできるのが素晴らし過ぎる。
既存プロジェクトの Deployment Target はiOS 13より前を指定したまま実装内容は変更せず、プレビュー機能のみを追加させる事ができるので導入も容易。

注意

SwiftUI がリンクされた状態でiOS 13より前のiOSで動作させるためには SwiftUI.framework の Weak Link が必要なのでお忘れなく。

f:id:Koze:20200306003253p:plain

Tipsとリファクタ

Tips 1

SwiftUI はプレビュー用途で使っているので実装ファイル自体をリリース版に含めたくないと思うかも知れない。
ところが SwiftUI のプレビューが記述されたファイルをターゲットから外すと

Cannot preview in this file -- active scheme dones not build this file

とエラーが出てプレビュー表示できなくなった。

f:id:Koze:20200306004542p:plain

なので SwiftUI による実装自体をリリース版に含めたくなければ全体を #if DEBUG #endif で囲むと良いと思う。*2

Tips 2

SwiftUI のプレビューは最適化レベルが -Onone でないといけないらしく、Scheme で Run の Buile Configuration が Debug に設定されていなかったり、Build Settings で最適化レベルを変更している場合は以下のエラーが出てプレビュー機能が使えない。

Cannot preview in this file -- not building -Onone

f:id:Koze:20200306004822p:plain

Tips 3

WWDC のビデオによればプレビューの表示の際に UIApplicationDelegateapplication(_:didFinishLaunchingWithOptions:) を通るらしく、ここの処理は軽くした方が良いらしい。
処理の退避先は UISceneDelegatescene(_:willConnectTo:options:) なのでiOS 13以降のみ対応のアプリでしか使えないリファクタ。

Tips4 - Development Assets

プレビューに使うデータをネットワークから引っ張ってくる実装になっていると、プレビューのリフレッシュの度にネットワークアクセスが発生してしまうので*3、プレビューに必要なJSONや画像をスタブを使ってローカルから読み込む様にする。
プロジェクトにスタブを含めつつリリース版には同梱しない様にする素晴らしい新機能 Development Assets が Session 233 で紹介されていた。 https://developer.apple.com/videos/play/wwdc2019/233/ 15分38秒あたりから

Target > General の一番下に追加されている Development Assets
ここで指定したアセットやフォルダは開発時のみにターゲットに含まれる様になる。 *4
1, 2分でさらっと紹介されているけど便利過ぎる。*5

f:id:Koze:20200306001947p:plain

リファクタ

プレビュー用の SwiftUI のファイルは別途作成しなくても良い。
もし View の実装が軽いのであれば既存の実装ファイルに直接 PreviewProvider を実装するのもアリかと。
各Tipsとリファクタを踏まえて、また UIViewRepresentable の要求する typealias UIViewType を設定して previews makeUIView updateUIView の各メソッドから型の直書きを削除したリファクタバージョンは以下。
これで既存プロジェクトへの簡単な SwiftUI Preview の追加が完成。

Adding SwiftUI preview to existing class

まとめ

  • Deployment Target iOS 13以降じゃなくても Swift UI の部分に @available(iOS 13.0, *) を指定して SwiftUI によるプレビュー機能を使って開発を行う事ができる
  • SwiftUI.framework の Weak Link を忘れず
  • スタブは Development Assets を使う

*1:for SwiftUI preview のコメント部分は別記事書くかも

*2:WWDC のビデオでは #if DEBUG を使っているのをよく見かけたけど現在の Xcode 11 では SwiftUI View を作成した際のテンプレートで #if DEBUG が使われていない。気になって古い Xcode を引っ張ってきて SwiftUI View のテンプレートを確認して見たところ Xcode 11 beta 5 までのテンプレートではプレビュー部分を #if DEBUG で囲っており Xcode 11 beta 6 のテンプレートから #if DEBUG の指定が無くなった模様

*3:既存アプリの ViewController でプレビュー機能を試してみたらプレビュー内でちゃんと広告バナーまで表示されてそれはそれでプレビュー機能の凄さに感動した

*4:クラスファイルは対象ではないっぽい

*5:最近他のセッションビデオを色々見てるけど、このセッションでしか触れられていない様な、、こんな便利な機能なのに

UIGraphicsBeginImageContextWithOptions をやめて UIGraphicsImageRenderer を使うメリット

WWDCのメモリのセッションを見ていたら思わぬTipsについて言及されていた。

WWDC 2018 iOS Memory Deep Dive の20分57秒あたり https://developer.apple.com/videos/play/wwdc2018/416

UIGraphicsBeginImageContextWithOptions を使うのはやめて UIGraphicsImageRenderer を使えという話題。

UIGraphicsImageRenderer は最適なグラフィックフォーマットを自動で選択してくれる。
マスク画像の様な単色の画像を描画する場合に UIGraphicsBeginImageContextWithOptions では1ピクセル4バイトのsRGBに対して UIGraphicsImageRenderer は1ピクセル1バイトで75%のメモリの節約になる。*1

さらに UIGraphicsImageRenderer で作成された単色の UIImageUIImageViewtintColor などで表示色の変更が可能で、複数の色の画像が欲しい場合にそれぞれの色でレンダリングして異なる UIImage を作成しなくても tintColor を各ビューに設定するだけで済むとの事。

*1:UIGraphicsImageRenderer はワイドカラーにも対応しているので、単色の場合はメモリの節約になるけどワイドカラーが適用されるケースでは1ピクセル8バイトで逆にメモリ使用量は倍に増える。

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側の渡す値が常に正しいのであればこの様な処理はいらないけど、現実にはそうではないので止む無くガード処理という感じ

Xcode 11.2 + Storyboard UITextView でクラッシュ

Xcode 11.2 で Storyboard か xib で UITextView を使っていると iOS 13.2 より前のバージョンでクラッシュするという凶悪なバグが発生している。
実際にクラッシュを起こしてみると以下の様に _UITextLayoutView ってクラスは無いとエラーメッセージが表示された。

*** Terminating app due to uncaught exception 'NSInvalidUnarchiveOperationException', reason: 'Could not instantiate class named _UITextLayoutView because no class named _UITextLayoutView was found; the class needs to be defined in source code or linked in from a library (ensure the class is part of the correct target)'

Stack Overflow

stackoverflow.com

Apple Developer Forums

forums.developer.apple.com

これらの書き込みをまとめると

  • 影響範囲は現行最新版の iOS 13.2 より前の全てのバージョン
  • 発生条件は Storyboard か xib で UITextView を使っている事
  • 回避策1 UITextView を全てコードで生成する
  • 回避策2 Xcode 11.1 を使う

回避策については Apple の人も同様に言っている https://forums.developer.apple.com/thread/125287#391939

対策

UITextView の Storyboard 生成をコード生成に置き換えるかどうかはプロジェクト次第(コスト次第)だと思う。
もしソースの公開されていない外部ライブラリを使っている場合、中でどういう実装をしているのか分からないので Xcode 11.1 に戻した方が無難そう。
何らかの事情で Xcode 11.2 を使い続ける場合、配布ビルドのみ Xcode 11.1 で行うのもアリかと。


余談

Developer Forums に _UITextLayoutView を動的に追加すれば良いなんてコメントもあった。 https://forums.developer.apple.com/thread/125287#391979

なるほどと思い実際に試してみたところ以下の Objective-C 実装を application(_:didFinishLaunchingWithOptions:) のタイミングで実行する事で iOS 12 でもクラッシュを回避する事ができた。
がしかしコメントにもある様に、実際にこの実装でリリースするのは如何なものかいう感じなので次のバージョンの Xcode が出るまでの開発中の一時凌ぎとして使うならといったところ。興味が湧いて実装しただけなので参考までに。*1

@interface UITextViewWorkaround : NSObject
+ (void)executeWorkaround;
@end

@implementation UITextViewWorkaround

+ (void)executeWorkaround {
    if (@available(iOS 13.2, *)) {
    }
    else {
        const char *className = "_UITextLayoutView";
        Class cls = objc_getClass(className);
        if (cls == nil) {
            cls = objc_allocateClassPair([UIView class], className, 0);
            objc_registerClassPair(cls);
#if DEBUG
            printf("added %s dynamically\n", className);
#endif
        }
    }
}

@end

_UITextLayoutViewsuperClassUIView である事は以下の記事の方法で確認した。

koze.hatenablog.jp

以上

追記

iOS と tvOS については Xcode 11.2.1 GM で修正された。
https://developer.apple.com/documentation/xcode_release_notes/xcode_11_2_1_gm_seed_release_notes/

*1:この実装を掲載した後まるっと StackOverflow に転載されていてびっくり

複数の AVSpeechSynthesizer の同時再生 iOS 13

最近 iOS 13 でバグも含め挙動が変わった点で色々と苦労している。そんな中良い改善点もあった。タイトルの複数の AVSpeechSynthesizer の同時再生がその1つ。

これまでは AVSpeechSynthesizer の同時再生はできなかった。できないと一口に言っても妙な挙動をするのでこの機会にまとめて整理しておく。

検証のためのコード

以下の様なソースコードでポチポチボタンをタップして色々なパターンで動作を確認してみた。

import UIKit
import AVFoundation

class ViewController: UIViewController {
    
    let synthesizerA = AVSpeechSynthesizer()
    let synthesizerB = AVSpeechSynthesizer()
    let synthesizerC = AVSpeechSynthesizer()

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }


    @IBAction func speak1() {
        let utterance = AVSpeechUtterance(string: "1, 1, 1")
        utterance.rate = 0.2
        synthesizerA.speak(utterance)
    }

    @IBAction func speak2() {
        let utterance = AVSpeechUtterance(string: "2, 2, 2")
        utterance.rate = 0.2
        synthesizerA.speak(utterance)
    }

    @IBAction func speak3() {
        let utterance = AVSpeechUtterance(string: "3, 3, 3")
        utterance.rate = 0.2
        synthesizerB.speak(utterance)
    }

    @IBAction func speak4() {
        let utterance = AVSpeechUtterance(string: "4, 4, 4")
        utterance.rate = 0.2
        synthesizerB.speak(utterance)
    }

    @IBAction func speak5() {
        let utterance = AVSpeechUtterance(string: "5, 5, 5")
        utterance.rate = 0.2
        synthesizerC.speak(utterance)
    }

    @IBAction func speak6() {
        let utterance = AVSpeechUtterance(string: "6, 6, 6")
        utterance.rate = 0.2
        synthesizerC.speak(utterance)
    }

    @IBAction func speakAll() {
        speak1()
        speak2()
        speak3()
        speak4()
        speak5()
        speak6()
    }
}

検証結果

記号の意味は以下とする
| は再生開始点
- は生成中
> は再生終了点

iOS 7

未確認

iOS 8~12

Bの開始時にAが再生中であればBは開始されない。

A  |----->
B      |   

Aの再生中にBを2度開始すると、2回目のBは順番待ちにスタックされ再生中のAの終わりにBの2つめが再生される。また2つ目のBのスタックはCによって打ち消されない。

A  |---------->
B      |
B        |     ----->

上記のケースでAの再生中にCを2度開始してもスタックされたBの2つ目が再生される。

A  |---------->
B      |
B        |     ----->
C          |
C            |

Aの再生中にBを2度開始してさらにAを開始するとAの終わりにはAの2つ目が再生される。
再生中のAへのスタックは他のスタックを打ち消す。

A  |---------->
B      |
B        |
A          |   ----->   
iOS 13

同時再生可能!
AとBは完全にパラレルでインスタンス毎に動作する。
音声再生中の次の音声再生開始はインスタンス毎にスタックされ他には影響しない。

A |------->
B |----------->
A       |  -------->
B       |      --------->

別の視点での利点

以下の様に実装した再生の一時停止と再生再開のコードを使って検証

    @IBAction func pauseOrResumeA() {
        if synthesizerA.isSpeaking {
            if synthesizerA.isPaused {
                synthesizerA.continueSpeaking()
            }
            else {
                synthesizerA.pauseSpeaking(at: .immediate)
            }
        }
    }

先ほどの図式に / を一時停止と再生再開として追加する。

iOS 12

これまでは同時再生ができなかったので、Aを再生後、一時停止をしてBを2回再生開始した場合でも、Aの再生再開と再生完了までBは永久に再生が行われないという問題(仕様)があった。

Aが一時停止中(isSpeaking && isPaused)だとBは永久に再生されない

A  |---------/
B      |
B        |

Aが再生再開(continueSpeaking)して再生終了(!isSpeaking)するとようやくBの再生が始まる。一時停止と再生再開の挙動を除くと最初のケースで説明した状況と同じ。

A  |---------/     /--->
B      |
B        |              ----->

実際の実装ケースでは画面別に AVSpeechSynthesizer を使っている場合、一時停止のまま別の画面に移動してしまうと他の画面で一切 AVSpeechSynthesizer 動作しなくなるという問題がある。このため画面から移動する際には強制的に AVSpeechSynthesizer を停止する必要がある。
さらにまた元の画面に戻った際に続きから再生を再開したいのであれば一時停止した位置を保持しておき、そこから新たに再生を開始するという工夫も必要。

iOS 13

同時再生が可能なので、Aが一時停止中だろうがなかろうがBは関係なく再生できる。

A  |---------/     /--->
B      |------------>

実装への影響としては複数の AVSpeechSynthesizer のインスタンスの同時再生が可能になったので、画面毎(もしくはView単位、Class単位)で使っている AVSpeechSynthesizer が再生中か、一時停止状態か、停止状態かを気にする必要がなくなった。
それぞれ別個に一時停止とその地点からの再生再開が可能。
他のインスタンスとの協調動作を気にかける必要が無く実装の切り出しや細分化もしやすくなる。