酢ろぐ!

カレーが嫌いなスマートフォンアプリプログラマのブログ。

VNRecognizeTextRequestを使って画像からテキストを取得する

iOS 13から画像から顔認識やバーコード、テキスト認識する Vision.framework が実装された。本記事では VNRecognizeTextRequest を使って画像からテキスト(String)を抽出する方法を紹介する。このクラスでは Convert Image to String をおこなう。

よく似たクラス名の VNDetectTextRectanglesRequest はテキスト領域の矩形情報を得るために使用する。これはOCRに掛ける前段階で使うと良さそうだ。VNDetectTextRectanglesRequest ではテキストを抽出できないので注意したい。

VNRecognizeTextRequest がサポートしている言語を調べる

VNRecognizeTextRequest がサポートしている言語を調べる。

let recognizeTextRequest = VNRecognizeTextRequest()
let languages = try! recognizeTextRequest.supportedRecognitionLanguages()
print("\(languages)")

iOS 15.4時点ではサポートされている言語は以下の通りである。日本語には対応していない。

["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant"]

中国語が含まれているなら日本語にも対応してほしいけれど、単純に言語話者の数が多い順にAppleは対応していっているのかもしれない。2世代先のiOS 17くらいでは対応しているのかもしれない。

2022年現在、画像から日本語のテキストを得たいのであれば、Googleの ML Kit on iOS を併用した方が良いかもしれない。本ブログでも後日紹介したい。

VNRecognizeTextRequestを使って画像からテキストを取得する

表題の通り VNRecognizeTextRequest を使って画像からテキストを取得する。対応言語に日本語が含まれていないため、ここでは英語版の「リングフィット アドベンチャー -Switch」のトレーニング結果画像を利用する。

f:id:ch3cooh393:20220328085253j:plain

// 解析対象であるターゲット画像を取得する
guard let input = UIImage(named: "sample_summary_en")?.cgImage else {
    return
}

// テキスト解析後に実行される (非同期処理/あとで呼ばれる)
let request = VNRecognizeTextRequest { (request, error) in
    if let results = request.results as? [VNRecognizedTextObservation] {
        let strings = results.compactMap { observation in
            observation.topCandidates(1).first?.string
        }
        // 得られたテキストを出力する
        print(strings)
    }
}

// テキスト解析を開始する
let handler = VNImageRequestHandler(cgImage: input, options: [:])
DispatchQueue.global(qos: .userInteractive).async {
    do {
        try handler.perform([request])
    } catch {
        print(error)
    }
}

以下の結果が得られた。

["Today\'s Results", "R Take Screenshot", "Consumed with Fitness", "USER_NAME", "Total Time Exercising", "16 min. 59", "sec.", "Total Calories Burned", "147.03 kcal", "Total Distance Run", "0.94 km", "Next"]

「16 min. 59 sec.」部分が「16 min. 59」と「sec.」に分割されてしまったが、それ以外に問題はなく高精度でテキストを取得できている。

日本語に対応していれば嬉しかったが残念ながらiOS 15現時点では対応していないので、前述の通りGoogleのML Kitを使うのが良いのかもしれない。

Xcode 13.3でアプリバイナリのexportに失敗する問題

昨日、App StoreにてXcode 13.3がリリースされた。早速アップデートしたところ、Bitriseにて特定のアプリで下記のようなエラーが発生した。コンパイル自体は問題なくその後のアーカイブの段階で失敗している。

error: exportArchive: ipatool failed with an exception: #<CmdSpec::NonZeroExitException: $ /Applications/Xcode-13.3.Release.Candidate.app/Contents/Developer/usr/bin/python3 /Applications/Xcode-13.3.Release.Candidate.app/Contents/Developer/usr/bin/bitcode-build-tool -v -t /Applications/Xcode-13.3.Release.Candidate.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin --sdk /Applications/Xcode-13.3.Release.Candidate.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk -o /var/folders/ds/8lz3xw2s09jfp13nlv1tpgjc0000gn/T/ipatool20220315-7746-1lue8qz/thinned-out/arm64/Payload/MY_APPLICATION.app/Frameworks/libswift_Concurrency.dylib --generate-dsym /var/folders/ds/8lz3xw2s09jfp13nlv1tpgjc0000gn/T/ipatool20220315-7746-1lue8qz/thinned-out/arm64/Payload/MY_APPLICATION.app/Frameworks/libswift_Concurrency.dylib.dSYM --strip-swift-symbols /var/folders/ds/8lz3xw2s09jfp13nlv1tpgjc0000gn/T/ipatool20220315-7746-1lue8qz/thinned-in/arm64/Payload/MY_APPLICATION.app/Frameworks/libswift_Concurrency.dylib

ローカルPCで試したところAdHocバイナリのexportでも同じエラーが発生した。

f:id:ch3cooh393:20220316101638p:plain

エラー内容から Swift concurrency に起因する問題だと理解できればよかったのだが、いろんなアプリでBuild Settingsを変更して差異を確認することになった。最終的には現象が異なるが「IllustailがiOS14で起動しない(解決) - CatHand Blog」と同様に Swift concurrency のバックポートにおける問題ではないかと判明した。

結論としては、本件はXcode 13.3における既知の不具合である。

Known Issues

Exporting an app that uses Swift’s concurrency features from an archive with bitcode might fail when the app targets iOS 13.0 – iOS 14.7, watchOS 6.0 – watchOS 7.6, or tvOS 13.0 – 14.7. (89271047)

Workaround: Either uncheck the box Rebuild from bitcode when exporting the app from an archive or disable bitcode (iOS only).

Xcode 13.3 Release Notes

iOS 15.0以下(iOS 13.0〜14.7)が、Deployment target に含まれているアプリ内で Swift concurrency を利用している場合にバイナリのexportに失敗してしまう。

Swift concurrencyを使用しているライブラリをインポートしていても同様なようで、アプリ内DBとしてRealmを使っているがアプリ内コードとしてSwift concurrencyを使っていない ptcgnote でも Enable Bitcode を ON にしたままではAdHocバイナリのexportに失敗してしまった。

解決策としては「Enable bitcodeをNOにする」か「deployment targetをiOS 15以上にあげる」のどちらかになりそうだ。

UIImagePickerControllerを使って写真.appで編集済みの動画を選択しても編集前のオリジナル動画が使われてしまう

動画のアップロード機能を実装していると、iOS標準の写真.appでクリッピングやトリミングしている編集済みの動画を選択した場合に、編集しているにもかかわらず編集前のオリジナル動画がアップロードされてしまう現象に気が付いた。

動画を選択するのに UIImagePickerController を使っている。未編集の動画を選択した場合とクリッピングした動画を選択した場合で違いがあるのかについて調査した。

結論から書くとUIImagePickerControllerは関係なく、PHAssetからAVAssetを取り出す際のPHVideoRequestOptionsの指定方法に誤りがあることがわかった。

編集済みの動画の場合はPHAsset#adjustedが1になっている

UIImagePickerControllerで動画や写真を選択するとUIImagePickerControllerDelegate.imagePickerController(_:didFinishPickingMediaWithInfo:)が呼ばれる。未編集動画と編集済み動画でどんな差異があるのか、info の中身をまず調べることにした。

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
  // ここでinfoの中身を調べた
  print("\(info)")
}

調査の結果、未編集の動画と編集済みの動画との違いはPHAsset#adjustedの値の違いだけであることがわかった。少なくとも info[.editedPhAsset] などからデータを取り出さないといけないわけではない。PHAssetから「編集前のオリジナル動画」ではなく「編集済みの動画」を取得するにはどうしたらよいのか?については後述する。

あまり重要な情報ではないが、それぞれの動画を選択した場合のログを載せてく。

編集済みの動画を選択した場合には、info[.phAsset]で取り出したPHAssetのadjusted1になっている。

[
 __C.UIImagePickerControllerInfoKey(_rawValue: UIImagePickerControllerMediaType): 
public.movie, 
 __C.UIImagePickerControllerInfoKey(_rawValue: UIImagePickerControllerPHAsset): 
<PHAsset: 0x13024f720> A6BC4CB3-E92F-4BB3-A967-5EA773F1DCDF/L0/001 mediaType=2/0, sourceType=1, (1920x1080), creationDate=2022-01-14 13:59:02 +0000, location=1, hidden=0, favorite=0, adjusted=1 , 
 __C.UIImagePickerControllerInfoKey(_rawValue: UIImagePickerControllerReferenceURL): 
assets-library://asset/asset.MOV?id=A6BC4CB3-E92F-4BB3-A967-5EA773F1DCDF&ext=MOV, 
 __C.UIImagePickerControllerInfoKey(_rawValue: UIImagePickerControllerMediaURL): 
file:///private/var/mobile/Containers/Data/PluginKitPlugin/F543103D-63B7-4566-B24D-758E8D85CA17/tmp/trim.E02629B0-C7EE-4FA1-998E-3EFE22F9D590.MOV
]

未編集の動画を選択した場合には、info[.phAsset]で取り出したPHAssetのadjusted0になっている。

[
__C.UIImagePickerControllerInfoKey(_rawValue: UIImagePickerControllerPHAsset): 
<PHAsset: 0x135141e30> D82F6058-C843-45E6-B82F-3D0614A6A5A5/L0/001 mediaType=2/0, sourceType=1, (1920x1080), creationDate=2022-01-14 13:59:02 +0000, location=1, hidden=0, favorite=0, adjusted=0 , 
__C.UIImagePickerControllerInfoKey(_rawValue: UIImagePickerControllerMediaType): 
public.movie, 
__C.UIImagePickerControllerInfoKey(_rawValue: UIImagePickerControllerReferenceURL): 
assets-library://asset/asset.MOV?id=D82F6058-C843-45E6-B82F-3D0614A6A5A5&ext=MOV, 
__C.UIImagePickerControllerInfoKey(_rawValue: UIImagePickerControllerMediaURL): 
file:///private/var/mobile/Containers/Data/PluginKitPlugin/F543103D-63B7-4566-B24D-758E8D85CA17/tmp/trim.20082119-CD92-4146-A76D-3AE767B38FCC.MOV
]

問題はUIImagePickerControllerで選択した動画情報(PHAsset)の取り出し方にあるわけではなくて、PHAssetからAVAssetを取得する際のオプションの指定の仕方が悪いことがわかった。

PHAssetからAVAssetの取得時にオプションでオリジナル動画かクリッピングした動画かを選択する

既存の実装では、PHAssetからAVAssetを取り出す際のPHVideoRequestOptionsの指定を PHVideoRequestOptionsVersion.original としていた。このため編集前のオリジナル動画がアップロードされていたことがわかった。

let options: PHVideoRequestOptions = PHVideoRequestOptions()
// 編集前のオリジナル動画がほしい場合
//options.version = PHVideoRequestOptionsVersion.original
// 編集後の動画がほしい場合
options.version = PHVideoRequestOptionsVersion.current

PHImageManager.default().requestAVAsset(forVideo: self, options: options, resultHandler: {(asset: AVAsset?, _: AVAudioMix?, _: [AnyHashable: Any]?) -> Void in
    DispatchQueue.main.async {
        if let urlAsset = asset as? AVURLAsset {
            let localVideoUrl: URL = urlAsset.url as URL
            completionHandler(localVideoUrl)
        } else {
            completionHandler(nil)
        }
    }
})

写真.appで編集したとしても同じ動画として保存した場合には、写真.appで見た場合にサムネイルが変わっていたとしても、オリジナル動画が上書きされるわけではなくて少なくともファイルパス等はそのまま残っている。編集後の動画が欲しい場合には PHVideoRequestOptions#versionの指定を PHVideoRequestOptionsVersion.current とする必要がある。

f:id:ch3cooh393:20220115101436p:plain

以上で問題は解決した。

UIImagePickerControllerを使って動画を録画しようとすると __TCCAccessRequest_block_invoke でクラッシュしてしまう

プロフィール画像などの静止画の撮影にUIImagePickerControllerを使うことはよくあり、UIImagePickerControllerを使って動画の録画ができることも知っていた。iOSアプリ開発に携わって11年ちょっと経つが、いままでUIImagePickerControllerを使って動画録画したことがなかった。

簡単に実装できると思っていたので下記のコードを書いた。

let vc = UIImagePickerController()
vc.delegate = self
vc.sourceType = .camera
vc.mediaTypes = [ "public.movie" ]
self?.present(vc, animated: true, completion: nil)

しかし、iOS 15.2の実機でこのコードを実行したところ、UIImagePickerControllerへの遷移時に謎のクラッシュが発生してしまった。スタックトレースが吐かれずに __TCCAccessRequest_block_invoke とだけ表示されている。

予期せぬTCCAccessRequest エラーに注意」によれば、以下のように記述があった。

カメラの使用、フォトライブラリの参照などを行う場合、info.plistに説明文の記述が必要。

  • カメラ : NSCameraUsageDescription
  • フォトライブラリ : NSPhotoLibraryUsageDescription

前述の通りプロフィール画像の撮影のために Info.plist にはすでに定義を追加済みである。他にもアプリ内で利用するためにPrivacy - Location When In Use Usage Description などの定義もすでに追加している。

f:id:ch3cooh393:20220111155554p:plain

まともなログが出ていないため原因がわからず、2時間ほど国内QAサイトやStack Overflowなどを検索してまわったがやはり原因がわからなかった。

ふと静止画撮影と動画録画の違いを考えたところ「音声の有無」に気付いた。結局これが当たりだったようで、クラッシュすることなく動画の録画画面に遷移できるようになった。

マイク使用の許可の定義 Privacy - Microphone Usage Description が足りていないのが原因だったようだ。

Swift Package Managerでマルチモジュール化しているアプリでCoreDataの.xcdatamodeldファイルが参照できない

注意:このプロジェクトではCoreDataの定義ファイルを TweetAlbum.xcdatamodeld としている。もし存在するかわからないが、本記事を読んで同じ現象でハマった方がいれば、TweetAlbumの部分は各々で読み替えていただきたい。

Appの外にあるCoreDataの.xcdatamodeldファイルを参照できない

Swift Package Manager (以下SwiftPMと記す)を使ったマルチモジュール構造でアプリを作っている。CoreDataの定義ファイル(.xcdatamodeld)をアプリに持たせるのではなくSwiftPMのCoreモジュールに持たせるとうまく参照できずに以下のエラーが発生していた。

[error] error:  Failed to load model named TweetAlbum
CoreData: error:  Failed to load model named TweetAlbum

TweetAlbum.xcdatamodeldをアプリ内に配置している段階では、以下のコードでNSPersistentContainerオブジェクトを取得できていた。

let container: NSPersistentContainer

container = NSPersistentContainer(name: "CoreData")

TweetAlbum.xcdatamodeldをSwiftPMのパッケージであるCoreモジュールに移動させた途端、うまく参照できなくなってしまった。ちなみにCoreモジュールは下図のように定義している。

解決編:Bundleから.xcdatamodeldファイルのパスを取得する

SwiftPMでマルチモジュール化している場合には、NSManagedObjectModelオブジェクトを指定して以下のようにNSPersistentContainer(name: String, managedObjectModel model: NSManagedObjectModel)を使って初期化する。

let container: NSPersistentContainer

let modelURL = Bundle.module.url(forResource: "TweetAlbum", withExtension: "momd")!
let model = NSManagedObjectModel(contentsOf: modelURL)!
container = NSPersistentCloudKitContainer(name: "TweetAlbum", managedObjectModel: model)

xcframeworks に対応した Rome を使って Carthage のキャッシュをS3にアップロードして Bitrise で超高速にビルドしよう! #bitrise #bitrisearticle

CarthageのキャッシュをS3で共有できる「Rome」がxcframeworksに対応して帰ってきた!!この記事はiOS Advent Calendar 2021の12日目の記事です。

Romeとは?

ライブラリを事前にビルドしておいてiOSアプリのビルド時間を短縮するプロダクトに「Carthage」がある。Carthageの成果物をローカル・またはS3のリモートでキャッシュしておいてBitriseなどのCIサービスでもビルド時間を短縮するプロダクトに「Rome」がある。どちらもポエニ戦争に由来する名称でCarthageもRomeもググラビリティが最悪である。

github.com

「Rome」のxcframework対応が長い間止まっていたので、さくさんはxcframework対応のために Carthage/Buildディレクトリをzipで固めてS3に置いておき、Bitriseでビルドするたびにzipファイルをダウンロードする方法で対応していた。

2021年11月26日に「Rome」がxcframeworkに対応して帰ってきた。本記事ではさくさんが個人開発しているアプリにRomeを再導入するまでの手順を書き残している。過去に Carthage および Rome を使ったことない方には不親切な内容となっているがご了承いただきたい。

はじめに

2021年11月にさくさんはM1チップ(Apple Silicon)搭載のMacBook Pro (16-inch, 2021)に移行した。このタイミングで前々より計画していたxcframework対応を一気に進めることになった。

Rosetta上でXcodeとiOSシミュレータを使えばxcframework対応をする必要はなかったが、全社的にRosettaを使わない方針でいくことになった。四苦八苦試行錯誤しながら会社で開発しているアプリのM1対応を進めた。Twitterでうめきながら愚痴りながらなんとか環境を整えることができた。副産物的に「Four Cropper」が macOS でも動くようになった。

xcframework対応にあたって可能な限りCocoaPodsの利用をやめてCarthageに移行させることになった。Carthageでxcframeworkでビルドしたい場合には以下のコマンドで実行すればよい。

carthage update --use-xcframeworks --platform ios --cache-builds

Carthageでビルドできなかったライブラリは SwiftPM か CocoaPods を使ってインストールすることで回避できた。Carthage での xcframework対応の話はこれで終わりだ。

Carthage/Build ディレクトリを zip でまとめて Bitrise でダウンロードしていたが……

さくさんは会社でも個人でもアプリのビルドには Bitrise を使っている。git にプッシュすると Bitrise でビルドして TestFlight にアップロード・配信する流れができている。xcframework 対応にあたり問題になったのが、Carthage の Carthage/Build ディレクトリをどうやって Bitrise で扱うかだ。

アプリに Realm が含まれているとBitriseの最大ビルド時間である1時間半を大幅に超えてしまい、ビルドが強制的にabortされてしまうため、Bitrise上ではcarthage bootstrapは使えない。さくさんは Carthage/Build ディレクトリをzipで固めてS3にアップロードしておき、Bitriseでビルドするたびにzipファイルをダウンロードする方法で対応していた。

Bitriseでは resource-archive ステップを使うと、zipファイルをダウンロードして解凍の流れを実行してくれる。bitrise.ymlでは以下のように書く。指定したBuild.zipをダウンロードして、FourCropper/Carthage/以下に解凍してくれる。

workflows:
  primary:
    steps:
〜〜省略〜〜
    - cocoapods-install@2: {}
    - resource-archive@2:
        inputs:
        - extract_to_path: FourCropper/Carthage/
        - archive_url: https://example.com/XXXXXXXXXXXX/Build.zip

Build.zip は単純に以下のスクリプトで生成した Carthage/Build ディレクトリを圧縮したファイルである。s3cmdなどのコマンドを使ってBuild.zipをS3へアップロードするだけで簡単だ。

carthage update --use-xcframeworks --platform ios --cache-builds
cd /Users/ch3cooh/works/FourCropper_ios/FourCropper/Carthage/
rm -f Build.zip
zip -r Build.zip Build/

ライブラリの少ないアプリの場合はこの方法で問題ないが、前述の Realm などクソデカライブラリ容量の大きなライブラリが含まれているとBuild.zipは1GB近くになってしまう。Bitriseの resource-archive ステップで1GB近いのzipファイルをダウンロードするには10分ほど掛かってしまう。早期になんとかしたいと考えていたが代替案がなくそのままになっていた。

Rome の v0.24.0.65 で xcframeworks に対応したので、この Carthage/Build ディレクトリ丸ごと圧縮作戦をやめてRomeに切り替えていく。

Romeの初期設定

Carthageの設定に関しては本エントリでは扱わない。Romeを使うための準備方法を紹介していく。

Romeのインストール

RomeはHomebrewでインストールすることもできるが、Bitriseでの利用を前提にして取り回しの効くCocoaPods経由でインストールさせる。Podfileに追記しておく。

pod 'Rome'

CarthageのキャッシュをS3へアップロードするための準備

S3にxcframeworkをアップロードしたいので、ローカルPCからAWSへ接続するための準備をおこなう。過去にAWS SDKを使ったことがあれば設定は必要ないと思う。

AWSのIAMとバケットを作成する

AWSのIAMとバケットを作成する。

  • AmazonS3FullAccess ポリシーを持つユーザー
  • Carthage/Build のビルド済み .xcframework を格納する S3バケット

アクションを縛りたいがうまく行かなかったのでバケットポリシーは全許可にしている。

{
    "Version": "2012-10-17",
    "Id": "PolicyXXXXXXXXXX",
    "Statement": [
        {
            "Sid": "StmtXXXXXXXXX",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::XXXXXXX_S3_BUCKET_NAME"
        }
    ]
}
AWSの認証情報を入力する

aws configurateを使っても良いが、Romeの説明でコマンドラインを使ってたのでそれに倣った。

mkdir -p ~/.aws/
touch ~/.aws/credentials
touch ~/.aws/config
~/.aws/credentials
[default]
aws_access_key_id=AKIXXXXXXXXXX
aws_secret_access_key=UvPzXXXXXXXXXXXXXXXXXXXX
~/.aws/config
[default]
region=ap-northeast-1

Romefileの書き方

基本的にRomeはCartfile.resolvedを参照してよしなにしてくれる。

しかしリポジトリ名と生成されるxcframeworkの名称が異なる場合はキャッシュ対象から除外されてしまう。たとえばSentryのリポジトリ名はsentry-cocoaだが生成されるxcframeworkの名前はSentry.xcframeworkとなっている。細かいところでは-_の違いでも対象から除外されてしまう。たとえばUITextView-Placeholder なのに生成されるのが UITextView_Placeholder.xcframework の場合などである。

Romefileの書き方には難解な部分がある。さくさんが個人開発しているFour Cropperで実際に使っているCartfileとRomefileの例を紹介する。

FourCropperのCartfileは下記の通りだ。FourCropper自体が小規模なアプリなので利用しているライブラリは少ない。

github "yhirano/LicensePlistViewController"
github "bizz84/SwiftyStoreKit"
github "TimOliver/TOCropViewController"
github "takecian/SwiftRater"
github "devxoul/UITextView-Placeholder"
github "getsentry/sentry-cocoa"

Romefile では前述したようにリポジトリ名と生成されるxcframeworkの名称を関連付けする必要がある。FourCropperのRomefileは下記の通りだ。

cache:
  s3Bucket: XXXXXXX_S3_BUCKET_NAME

repositoryMap:
- TOCropViewController:
    - name: CropViewController
    - name: TOCropViewController
- sentry-cocoa:
    - name: Sentry
- UITextView-Placeholder:
    - name: UITextView_Placeholder

AWS SDKの場合リポジトリひとつに対して生成される xcframework が20以上あるので設定がややこしくなる。iOSエンジニア各位はどのライブラリをインストールしたらなんの xcframework が生成されているのか理解していると思うので、おそらくこの関連付けの作業は難なくこなせるかと思う。

以上でRomeの設定はできた。

ローカルPCでのRomeの使い方

S3へCartageのキャッシュのアップロードするにはローカルPC上で下記のコマンドを実行する。毎回すべてのライブラリをアップロードするのはS3の転送量的にも所要時間的にも勿体無いので、差分のみをアップロードする方法を採るべきだろう。

export SWIFT_VERION=`xcrun swift --version | head -1 | sed 's/.*\((.*)\).*/\1/' | tr -d "()" | tr " " "-"`

./Pods/Rome/rome download --use-xcframeworks --cache-prefix $SWIFT_VERION
carthage update --use-xcframeworks --platform ios --cache-builds
./Pods/Rome/rome list --missing --use-xcframeworks --cache-prefix $SWIFT_VERION | awk '{print $1}' | xargs -I {} ./Pods/Rome/rome upload "{}" --use-xcframeworks --cache-prefix $SWIFT_VERION

ローカルでXcodeを使う際には事前に下記のコマンドを実行する。これだけでS3から必要なxcframeworkをダウンロードできる。

export SWIFT_VERION=`xcrun swift --version | head -1 | sed 's/.*\((.*)\).*/\1/' | tr -d "()" | tr " " "-"`

./Pods/Rome/rome download --use-xcframeworks --cache-prefix $SWIFT_VERION

BitriseでのRomeの使い方

BitriseでRomeを使う際には一点注意することがある。xcframeworksに対応したRome v0.24.0.65は S3からキャッシュデータをdownloadする際にかならず失敗してしまう問題がある。すでにIssueが起票されているので修正はされると思うので、それまでの回避策を含めてBitriseでのRomeの使い方を紹介する。

Install Carthage/Build ステップ が成功しても失敗しても、次のステップから普通に実行したい場合どうするのか?

GUI上から「発生したエラーを無視してスキップする設定」ができないので、bitrise.ymlでis_skippable: trueを付与すれば良い。

    - cocoapods-install@2: {}
    - script@1:
        title: Install Carthage/Build
        is_skippable: true 
        inputs:
        - content: |-
            #!/usr/bin/env bash
            cd FourCropper
            export SWIFT_VERION=`xcrun swift --version | head -1 | sed 's/.*\((.*)\).*/\1/' | tr -d "()" | tr " " "-"`
            ./Pods/Rome/rome download --use-xcframeworks --cache-prefix $SWIFT_VERION
    - cache-push@2:
        inputs:
        - cache_paths: |-
            $BITRISE_CACHE_DIR
            FourCropper/Carthage -> FourCropper/Cartfile.resolved
            FourCropper/Pods -> FourCropper/Podfile.lock
            FourCropper/vendor -> FourCropper/Gemfile.lock

bitrise.ymlの設定が終われば、あとはBitriseから該当のAWS S3にアクセスできるようにアクセスキーを登録しておこう。AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_REGION を忘れないで。

以上でBitriseでRomeを使う設定は完了だ。

xcframeworks対応のRomeを使ってみた結果ビルド速度が超高速になった!

Carthage/Buildまるごとzipで固める作戦から Rome に移行した。ビルド時間がどれだけ早くなったのかを確認していこう。

Four Cropperでのビルド時間は約12分から約9分へ短縮した。1ビルドの所要時間を約25%軽減することができた。

Four Cropperでのビルド時間は約12分から約9分へ短縮した

別のアプリAでのビルド時間は約16分から約13分へ短縮した。アプリAではアプリ内DBとしてRealmを使っている。1ビルドの所要時間を約20%軽減することができた。

アプリAでのビルド時間は約16分から約13分へ短縮した

別のアプリBでのビルド時間は約50分から約37分へ短縮した。このアプリではresource-archiveステップで9分〜10分かかっていた。この時間がマルッとなくなったのが嬉しい。1ビルドの所要時間を約25%軽減することができた。

アプリBでのビルド時間は約50分から約37分へ短縮した

追記:突然Romeが使えなくなることがある……

Rome v0.24.0.65 はやはり不安定なのかもしれない。とあるライブラリのインストール先を「元リポジトリ」と「フォークしたリポジトリ」とのどちらにするか実験するために、ライブラリのアップデート+Romeへのアップロードを繰り返していたところ、突然以下のエラーが発生するようになってromeが使えなくなってしまった。

rome: device /dev/random cannot be grabbed
CallStack (from HasCallStack):
  error, called at ./Crypto/Random/Entropy/Unix.hs:60:20 in cryptonite-0.26-1BsgpeCLUsqwiNhmB6AoC:Crypto.Random.Entropy.Unix

最終的には romeを一旦削除して再インストール、もう一度試したら正常に動くようになった。今回はCocoaPods経由でインストールしているためPodsディレクトリを削除して pod install で事なきを得た。

さいごに

この記事をみて Bitrise を使ってみたくなった方がもしいれば「https://app.bitrise.io/referral/a927d5dbff07cc1d」からBitriseのアカウントを作成してくれると嬉しい。

現在Bitriseの無料カウントでは1ビルドあたり30分までビルド時間を使うことができる。この紹介リンクからアカウントを作るとビルド時間が5分伸びるのでとても便利になる。GitHubのアカウントを持っていれば数クリックでBitriseのアカウントも作成できる。この機会にBitriseを知った方には是非利用して欲しい。

備忘録:SwiftUIのListでrefreshした時に前回のデータが表示されている。画面が再描画されると新しいデータが反映される

備忘録として書き残しておく。気が向いたら書き直すかもしれない。


SwiftUIのListで下図のような画面を実装している。

縦スクロールと横スクロールの混ざったリストは珍しくないが、この画面の厄介なところは横スクロールでも別途APIを叩いてデータを取得する必要がある。1画面1APIではなくて、縦スクロール用に1APIと横スクロール分x1API叩く必要がある。実装にめちゃくちゃ苦労しているが、APIの話はここではあまり関係ない。

f:id:ch3cooh393:20211203091355p:plain

実装としてはこんな感じだ。実際のコードとは全然違うのでイメージとして受け取ってください。

    var body: some View {
        List {
             ForEach(vItems) { hItems in
                 HorizontalScrollView(items: hItems)
             }
         }
    }

この画面は上から下方向へスワイプさせることで画面をrefreshさせる。ここではrefreshとは「オブジェクトは一旦削除して再度追加してListに表示させること」を指すことにする。

refreshしても画面の表示が変わらない。前回のデータが引き続き表示されてしまっているようだ。HorizontalScrollViewに触るなどするとViewの更新が掛かってAPIから取得した新しいデータが反映される。

Listが前回のデータをキャッシュしている疑いが濃厚だが、何を基準にしてキャッシュしているのかわからない。HorizontalScrollViewに渡しているObservableObjectの配列は以下の通り。最初は id を基準にしているのかと考えた。

class ItemModel: ObservableObject, Identifiable, Equatable {

    // ... (省略)

    // MARK: - Identifiable

    var id: String {
        item_id
    }

    // MARK: - Equatable

    static func == (lhs: ItemModel, rhs: ItemModel) -> Bool {
        return lhs.id == rhs.id
    }
}

なんで前回のデータが表示されているのかがわからなかったが「キャッシュされている」「おそらくidで判定している」と当たりがついたので、SwiftUIのListのキャッシュについて調べていくと「ios - SwifUI ForEach List keeps modified values when reloading a @Published array inside ObservableObject - Stack Overflow」が出てきた。

アイテム数が同じだったらどうするのなど考えていないのであまり良い例ではないが Equatable の処理を下記のように変更したところ、refreshした際にきちんと新しいデータが描画されるようになった。

class ItemModel: ObservableObject, Identifiable, Equatable {

    // ... (省略)

    // MARK: - Identifiable

    var id: String {
        item_id
    }

    // MARK: - Equatable

    static func == (lhs: ItemModel, rhs: ItemModel) -> Bool {
        return lhs.id == rhs.id && lhs.items.count == rhs.items.count
    }
}

国居さんにも教えてもらった。