ObjecTips

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

Xcode 11 で iOS 14 Simulator を使用する

Xcode 12 への移行はまだ先の予定だけど取り敢えず iOS 14 で動作確認・修正作業をしたいなんてケースがある。
そこでタイトルの「Xcode 11 で iOS 14 Simulator を使用する」これができるらしい。

stackoverflow.com

手順

  • Xcode 12 付属の iOS Simulator を起動 *1
    • Xcode 12 を起動して Xcode > Open Developer Tool > Simulator を選択して起動
    • もしくは Xcode 12 内臓の Xcode.app/Contents/Developer/Applications/Simulator.app を起動
  • Simulator.app で File > Open Simulator > iOS 14.0 から任意のデバイスを選択して開く
  • Xcode 12 を起動していたら終了する
  • Xcode 11 を起動する
  • Xcode 11 の Run Destination のリストに起動中の iOS 14 Simulator が表示されて Run できる

メリット

  • Xcode 11(iOS 13 SDKビルド)+ iOS 14 の組み合わせでの動作確認が簡単にできる
  • iOS 14 beta を入れた実機をたくさん用意しなくて良い

Xcode 12 と iOS 14 のリリース後もプロジェクトの完全移行までしばらくの間 Xcode 11(iOS 13 SDK)を使ってビルドを続けるケースはままあるのでこの技はしばらく使えそう。

*1:Xcode 12 の iOS Simulator はドックに入れておくと次から楽

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