Swift String の合成文字の Equatable 判定
String
でひらがなの「が」と「か」+「濁点」は等価か?
の実験メモ
The test for Equatable of precomposed characters …
という事で答えは true
厳密な区別が必要ない場合はカジュアルに等価比較して良さそう。
Xcode 11 で iOS 14 Simulator を使用する
Xcode 12 への移行はまだ先の予定だけど取り敢えず iOS 14 で動作確認・修正作業をしたいなんてケースがある。
そこでタイトルの「Xcode 11 で iOS 14 Simulator を使用する」これができるらしい。
手順
- Xcode 12 付属の iOS Simulator を起動 *1
- Xcode 12 を起動して
Xcode > Open Developer Tool > Simulator
を選択して起動 - もしくは Xcode 12 内臓の
Xcode.app/Contents/Developer/Applications/Simulator.app
を起動
- Xcode 12 を起動して
- 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
ヒラギノフォントが切れる問題 SwiftUI編
検証環境 Xcode 11.4.1
iOS+ヒラギノ+UILabel とか UIButton でググると過去の UIKit での問題が参照できます。
この問題は SwiftUI でも発生します。
まずサンプルとしてヒラギノ角ゴのW3を指定して Text
を作成。
(デバッグのため青色の枠線も表示)
Japanese font without Japanese character causes th ...
上の日本語を含まない状態だと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) } }
表示領域は大きくなっても文字は切れたまま。
次は Text
の baselineOffset
を試してみる。*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) } }
文字が切れなくなった。
SwiftUI の Font
からはフォント情報の descender
を取得する事ができないため別途 UIFont
を使っている。これを CTFont
を使う様にリファクタリング。
UIFont
の descender
は正の値が入っているが CTFont
の descender
は負の値が入っているので 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) } }
見た目の結果は1つ前の実装と同じ。
でも今回の実装の場合 Text
の箇所で Font
を都度 CTFont
から init
するため、表示箇所が多い場合はFont
と UIFont
を1つずつ作成する前回の実装の方が動作としては効率的な気もする。
コードの構造的には今回の CTFont
を使った実装の方が整頓されていると思う。
現在の実装では baselineOffset
で調整した分テキスト全体が上に移動するため、表示文字や周りのUIとの組み合わせによっては見た目が気になってくる。
(↑文字全体が上にシフトしているのが気になる。日本語の場合特に。)
SwiftUI には便利な offset
や padding
が存在するので、これらで baselineOffset
による移動を再調整してやる。
offset
Text("Copy") .font(.init(ctFont)) .baselineOffset(ctFontDescender) .border(Color(.systemRed)) .offset(y: ctFontDescender / 2) .border(borderColor)
padding
Text("Copy") .font(.init(ctFont)) .baselineOffset(ctFontDescender) .border(Color(.systemRed)) .padding(.top, ctFontDescender) .border(borderColor)
これで見た目の問題も解消された。
最後にお好みで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 ...
既存プロジェクトで 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 を選択して作成。
ここでは例として TableViewCellPreview.swift を作成。
作成したファイルを開くとまず available in iOS 13 or newer
のビルドエラーが起きるのでまず Xcode の自動Fixに従って @available(iOS 13.0, *)
を設定をしてエラーを修正。
するとひとまずプレビューが表示される。
テンプレートで作成されている struct TableViewCellPreview: View
は SwiftUI によるView実装で、既存クラスのプレビューだけなら不要なので削除。
struct TableViewCellPreview_Previews: PreviewProvider
の名前もくどいのでstruct TableViewCellPreview: PreviewProvider
に変更(お好みで)
前者の TableViewCellPreview
を削除した事によりまたエラーが起きるので static var previews: some View
の返り値は一旦適当な SwiftUI View にしておく。(例では Text("Text")
)
んで、以下のセッションで紹介されている 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
より大きい)場合はセルのラベルを縦レイアウトにするという実装をしている。
Accessible TableViewCell like system UITableViewCe ...
TableViewCellPreview
によるプレビューをキャンバスに表示した結果は以下になる。
期待通りの結果!
実機 or Simulator での実行や Environment Overrides、デバイス設定の変更などせず複数の状況を一度にプレビューできるのが素晴らし過ぎる。
既存プロジェクトの Deployment Target はiOS 13より前を指定したまま実装内容は変更せず、プレビュー機能のみを追加させる事ができるので導入も容易。
注意
SwiftUI がリンクされた状態でiOS 13より前のiOSで動作させるためには SwiftUI.framework の Weak Link が必要なのでお忘れなく。
Tipsとリファクタ
Tips 1
SwiftUI はプレビュー用途で使っているので実装ファイル自体をリリース版に含めたくないと思うかも知れない。
ところが SwiftUI のプレビューが記述されたファイルをターゲットから外すと
Cannot preview in this file -- active scheme dones not build this file
とエラーが出てプレビュー表示できなくなった。
なので SwiftUI による実装自体をリリース版に含めたくなければ全体を #if DEBUG
#endif
で囲むと良いと思う。*2
Tips 2
SwiftUI のプレビューは最適化レベルが -Onone
でないといけないらしく、Scheme で Run の Buile Configuration が Debug に設定されていなかったり、Build Settings で最適化レベルを変更している場合は以下のエラーが出てプレビュー機能が使えない。
Cannot preview in this file -- not building -Onone
Tips 3
WWDC のビデオによればプレビューの表示の際に UIApplicationDelegate
の application(_:didFinishLaunchingWithOptions:)
を通るらしく、ここの処理は軽くした方が良いらしい。
処理の退避先は UISceneDelegate
の scene(_: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
リファクタ
プレビュー用の 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
で作成された単色の UIImage
は UIImageView
の tintColor
などで表示色の変更が可能で、複数の色の画像が欲しい場合にそれぞれの色でレンダリングして異なる UIImage
を作成しなくても tintColor
を各ビューに設定するだけで済むとの事。
*1:UIGraphicsImageRenderer はワイドカラーにも対応しているので、単色の場合はメモリの節約になるけどワイドカラーが適用されるケースでは1ピクセル8バイトで逆にメモリ使用量は倍に増える。
AVSpeechSynthesizer の delegate メソッド willSpeakRangeOfSpeechString でクラッシュする危険性
AVSpeechSynthesizer
の delegate メソッド speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:)
は音声読み上げの際にこれから読み上げる箇所を NSRange
で知らせてくれる。
このメソッドを使って読み上げテキスト全体のうち今どこが読まれているのかを知る事ができ、ハイライト表示などにも利用する事ができる。
NSRange
の location
にはテキストの開始位置、length
には長さが入る。
読み上げ中のテキスト全体は AVSpeechUtterance
から speechString
で取得可能。これから読み上げる部分テキスト取得の具体的な実装は以下
NSRange
をそのまま使うパターン、Swift.String
を NSString
にキャストする
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) { let substring = (utterance.speechString as NSString).substring(with: characterRange) print(substring) }
もしくは NSRange
を Range<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 で問題が確認された。
不正な値は NSRange
の length
が -1
になっている。
本来 NSRange
は適切な範囲を取得できない場合は location
に NSNotFound
、length
には 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).
と書かれており、length
は 0
を取り得るものの、マイナス値については言及が無く NSNotFound
の様な定数も存在していない。
この length
に -1
が入った NSRange
をそのまま先の substring 取得の実装で使用すると
NSRangeException
が発生してアプリはクラッシュしてしまう。
不正な NSRange の原因
NSRangeException
のメッセージには Range {7, 18446744073709551615} out of bounds;
と書かれていた。
NSRange
は location
length
共に Int
型であり、最大値は Int.max
= NSInteger.max
は 9223372036854775807
。
NSNotFound
もドキュメントによれば同じ値になる。
NSNotFound is now formally defined as NSIntegerMax
NSNotFound - Foundation | Apple Developer Documentation
しかし実際に Exception で表示されている値は 18446744073709551615
これは NSUIntegerMax
の値になっている。
つまり NSRange
の Int
型に 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
型に変更になったのだと思われる。
NSRange
の location
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 側の処理で何かしらの例外が発生した際に NSRange
の NSUInteger 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)
以上