- 投稿日:2021-12-05T20:39:23+09:00
iOSアプリでSPMを用いたマルチモジュール構成を試してみた
近年のiOSアプリ開発では、アプリの規模が大きいアプリも増え、複数人による並行開発を安定的に行うべく、マルチモジュール構成を採用するアプリも増えてきたと思います。 特にマルチモジュールの流れはAndroidの方が先行していた印象で、2018年頃からその流れが大きくなってきた印象でした。 iOSDCなどカンファレンスのセッションなどを聞いていると、大規模アプリほどその課題にあたっているチームが多いようです。 従来のマルチモジュール構成を行うには、XcodeからFrameworkプロジェクトを作成し、ライブラリとしてメインのアプリプロジェクトに追加していくというものでした。 アプリと、ライブラリ間の依存関係にはXcode上で手動で行う方法や、CocoaPodsなどパッケージ管理ツールを使う方法もあります。 そんな中、iOSDC 2021の@d_dateさんのセッションで、Swift Package Managerを用いたマルチモジュール構成の紹介がありました。 今回この記事では、こちらの発表内容を参考にしつつ、実際にSPMでマルチモジュール構成を取ると、どのようなメリット、デメリットがあるか、サンプルアプリを作り検証してみました。 iOS15もリリースされ、2021年の年末からサポートOSをiOS14/iPadOS14以降とするアプリも多いかと思いますので、iOS14/iPadOS14以降をターゲットとしました。 また、アプリはFull SwiftUIで作成し、iOS14から利用できるコンポーネントを使ったアプリとしていますので、これからSwiftUIを勉強していく方にも参考にしていただけると思います。 2021/12現在の開発環境 Mac Book Pro 16-inch 2019(2.4GHz 8-Core Intel Core i9, 32GB 2667MHz DDR4) macOS Big Sur v11.6 Xcode v12.5.1 サンプルアプリ GitHubのSearch APIを利用した、リポジトリを検索&表示するアプリです。 iOS Home Search WebContent iPadOS 全体のモジュール構成とアーキテクチャは以下のようになっており、MVVM+Clean Architedtureを採用しました。 Feature Modulesという4つの画面モジュールグループと、Core Modulesというそれ以外のモジュールグループとなっています。 Module Type Module Name Description App App アプリエントリポイントを持つのモジュール。Rootのみ依存を持つ。 Feature Modules Root TabViewを持つViewで、HomeとSearchの画面の依存を持つ。 〃 Home Home画面のモジュール。WebContent、ViewComponents、Repositoriesに依存を持つ。 〃 Search Search画面のモジュール。WebContent、ViewComponents、Repositoriesに依存を持つ。 〃 WebContent WebView画面のモジュール。ViewComponentsに依存を持つ。 Core Modules ViewComponents 共通で使われる画面コンポーネントを集めたモジュール。今回はImageライブラリNukeUIを利用しているコンポーネントがあるため、NukeUIの依存を持つ。 〃 Repositories APIアクセスやローカルデータアクセスを抽象化したクラスを集めたモジュール。GitHubAPIRequestの依存を持つ。 〃 GitHubAPIRequest GitHubAPIのリクエストクラスやレスポンスEntityを集めたモジュール。APIClientの依存を持つ。 〃 APIClient APIClientを含むモジュール Xcode上のプロジェクトツリーはこのような見た目になっています。 SPMモジュールの作成の仕方 File > New > Swift Package...を選択します。 そうすると、ダイアログが表示されますので、名前、ディレクトリ、追加プロジェクト、プロジェクトツリーのグループを指定するだけです。 それでは、ここからは、SPMマルチモジュール構成のプロジェクトのメリット、デメリット、課題などをまとめていきます。 SPMマルチモジュールによるメリット メリット1: プロジェクトファイルのコンフリクト問題からの開放 ここで注目したいのが、SPMで追加した場合、"ディレクトリの参照"としてプロジェクトツリーに追加されるという点です。(フォルダが青) 通常Xcodeのファイルはプロジェクトファイル(project.pbxproj)にツリー構成が書き込まれ、ファイルの追加、削除、移動を行うとプロジェクトファイルも自動更新され、複数人で開発する場合のコンフリクトの要因となり、アプリ開発者は悩まされてきました。 その解決策として、XcodeGenが登場し、自動的にディレクトリとpbxprojファイルを同期させるアプローチが生まれました。 それに対し、SPMのディレクトリ参照となるため、SPMモジュールのルートディレクトリだけがpbxprojファイルに記述され、そこから配下のファイルはpbxprojファイルに依存を持ちません。 従って、SPMモジュール内のファイルの追加、削除、移動をしてもpbxprojファイルのコンフリクトは発生しなくなります。 エントリポイントのAppモジュールはRootモジュールだけ依存を持ち、以下のコードのみとなります。 App/Main.swift import SwiftUI import Root @main struct Main: App { var body: some Scene { WindowGroup { RootView() } } } メリット2: Package.swiftによる依存管理 各モジュールがどのモジュールに依存するかが、Package.swiftで管理できるため、とても明確でシンプルになります。 FeatureModules/Home/Package.swift // swift-tools-version:5.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Home", platforms: [ .iOS(.v14), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "Home", targets: ["Home"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), .package(path: "WebContent"), .package(path: "../CoreModules/ViewComponents"), .package(path: "../CoreModules/Repositories"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "Home", dependencies: [ "WebContent", "ViewComponents", "Repositories", ]), .testTarget( name: "HomeTests", dependencies: ["Home"]), ] ) また、Package.swiftに変更を加えると、Xcode自動的に構文チェックをしてくれ、エラーや警告を表示してくれます。 このあたり、Frameworkを作成して、依存管理する場合に比べ、ビルドしなくても依存問題を検知できるのは大きなメリットだと思います。 メリット3: Frameworkに比べ管理ファイルや変更点が少ない ここで、従来からあるFramework追加の場合と比較してみましょう。 新しくTargetの追加からFrameworkを選び、アプリのライブラリとして追加していきます。 Frameworkの追加 こうした場合、Swiftコード以外にもヘッダーファイル(.h)、Info.plistのファイルがあり、pbxprojファイルにも大量のツリー構成が挿入されます。 こういった余計なファイルや、コンフリクトの要因となるファイルから脱却でき、pbxprojファイルをシンプルに保つことができます。 メリット4: モジュール単位のビルド時間 これは、Frameworkの場合でも恩恵を受けられるメリットですが、モジュール単位でビルドができるため、フルビルドに比べ修正した個別のモジュールを選んだビルドは早くなりますので、大規模アプリになるほど恩恵は大きくなると思います。 ここからは、SPMマルチモジュールによるデメリットを考えていきたいと思います。 SPMマルチモジュールによるデメリット デメリット1: SPMの学習コスト 当然Package.swiftの書き方を学ぶ必要があるため、シングルモジュールに比べると追加の学習コストとなると思います。 デメリット2: CI環境の変更 マルチモジュールになり、テストコードもモジュール単位となるため、既存のCIがある場合、各モジュールごとのテストも実行するように変更が必要となってくるでしょう。 と2つほど上げてみましたが、逆にそれ以外のデメリットはなさそうという印象です。 XcodeGenなどに比べても管理ファイルがPackage.swiftだけなので、今後のXcodeのアップデートによるメンテナンスコストの影響も抑えられそうです。 最後に今回アプリを開発していて、分かってきたSPMの懸念点とSwiftUIの懸念点をお伝えしたいと思います。 懸念点 懸念点1: Swift Packgeのモジュールを追加した際、Xcodeが怪しい挙動をする場合がある 新たにSwift Packageを追加した際、XcodeのSchemeが突如表示されなくなったり、それまで通っていたimport Foo が No such moduleのエラーになることがありました。 こういう場合は一度Xcodeを立ち上げ直すと直ったり、変更したファイルをgit上で戻して追加し直すなどやっていると解消したりしました。 SPMあたりはまだ若干Xcodeが不安定な感じがします。 懸念点2: SwiftUIプレビューは頻繁に壊れる これはSPMマルチモジュールとは別の問題なのですが、今回作成したFull SwiftUIのアプリのコード量でもプレビューはかなり不安定でした。 ViewComponentsあたりの軽いモジュールは安定しやすいですが、画面全体のモジュールになってくると、エラーがよく発生し、クリーンをしてみる、実機ビルドをしてみる、Xcodeを再起動してみるといったことを頻繁に繰り返す必要がありました。 また、プレビュー時のビルドはモジュールがキャッシュされているような感じはなく、フルビルドがよく走っている印象です。 そのため、もし既存のアプリで画面単位でのSwiftUIプレビューの安定化を期待しても、変わらない可能性が高いと思われます。 もしかしたら今後のXcodeのバージョンアップで改善されるかもしれません。(改善してほしい...) 最後に 今回サンプルアプリを作成するにあたり、主に以下の技術書、資料を参考にさせていただきました。 もし何か間違いや、ご意見、感想などありましたらコメント頂けたら嬉しいです。
- 投稿日:2021-12-05T20:35:00+09:00
ListでのNavigationLinkとButtonを使った画面遷移
List 内で NavigationLink で isActive を使用し、 Button で isActive を管理した時に isActiveが機能しなくなることがあったので対処法を記しておきます。 Listを使用しないときの NavigationLink と Button の組み合わせ List を使用しない場合、 NavigationLink と Button を併用し isActive の Bool値を falase で固定すると、当然ですが画面遷移は起きません。 ContentView struct ContentView: View { @State var openDetailView = false var body: some View { ZStack { NavigationLink( destination: DetailView, isActive: $openDetailView, label: { EmptyView() } ) Button(action: { //self.openDetailView = true }, label: { Text("画面遷移") }) } } var DetailView: some View { Text("DetailView") } } 上記のコードのListを追加する では、上記のコードにListを追加してみます。 ContentView struct ContentView: View { @State var openDetailView = false @State var selectedIndex = 0 var body: some View { List(1..<11) { index in ZStack { NavigationLink( destination: DetailView, isActive: $openDetailView, label: { EmptyView() } ) Button(action: { //self.openDetailView = true selectedIndex = index }, label: { Text("\(index)の画面遷移") }) } } } var DetailView: some View { Text("\(selectedIndex)のDetailView") } } ここに List を追加すると isActive の値が false であるにも関わらず画面遷移が発生してしまします。 Listを使用した NavigationLink と Button のベストプラクティス NavigationLink と ZStack を List の外に出すことで isActive を挙動通りに動かすことができます。 ContentView struct ContentView: View { @State var openDetailView = false @State var selectedIndex = 0 var body: some View { ZStack { NavigationLink( destination: DetailView, isActive: $openDetailView, label: { EmptyView() } ) List(1..<11) { index in Button(action: { //self.openDetailView = true selectedIndex = index }, label: { HStack { Text("\(index)の画面遷移") Spacer() Image(systemName: "chevron.right") } }) } } } var DetailView: some View { Text("\(selectedIndex)のDetailView") } } コメントアウトを外せば挙動通り、画面遷移が行われます。 NavigatoinView の定義 NavigationView がないと NavigationLink は機能しないので、定義する必要があります。 ContentViewで使用しても良いですし、 ContentViewで使用する場合 var body: some View { ZStack { NavigationLink( destination: DetailView, isActive: $openDetailView, label: { EmptyView() } ) Button(action: { //self.openDetailView = true }, label: { Text("画面遷移") }) } } {アプリ名}App.swift ファイルに指定してもOKです。 {アプリ名}Appの場合 @main struct App: App { var body: some Scene { WindowGroup { NavigationView { ContentView() } } } }
- 投稿日:2021-12-05T16:43:02+09:00
【SwiftUI】Firebase Authenticationでログイン機能を作ってみる
前提 こちらの記事の続きでFirebaseAuthを用いてログイン機能を実装します。 参考 導入手順などは公式サイトの手順通りに行いました。 GitHub 実装 アプリ起動時にFirebase.configure()を呼びます。 import SwiftUI import Firebase @main struct SwiftUI_MVVM_LoginApp: App { init() { FirebaseApp.configure() } var body: some Scene { WindowGroup { NavigationView { ContentView(viewModel: ContentViewModel(firebaseAuthService: FirebaseAuthService.shared)) } } } } FirebaseAuth用のServiceを作成します。 シングルトンで作ってみました。 addStateDidChangeListenerのクロージャーで受け取ったuserを保持しておけば ログイン中のユーザー情報をこの常にこのServiceから取得することもできます。 import FirebaseAuth protocol IFirebaseAuthService: AnyObject { func addStateDidChangeListener(completion: @escaping (Bool) -> Void) func removeStateDidChangeListener() func signIn(email: String?, password: String?, completion: @escaping (Error?) -> Void) func signUp(email: String?, password: String?, completion: @escaping (Error?) -> Void) func signOut() } final class FirebaseAuthService: IFirebaseAuthService { public static let shared = FirebaseAuthService() private var handler: AuthStateDidChangeListenerHandle? private init() {} deinit { removeStateDidChangeListener() } func addStateDidChangeListener(completion: @escaping (Bool) -> Void) { handler = Auth.auth().addStateDidChangeListener { (auth, user) in if let _ = user { completion(true) } else { completion(false) } } } func removeStateDidChangeListener() { if let handler = handler { Auth.auth().removeStateDidChangeListener(handler) } } func signUp(email: String?, password: String?, completion: @escaping (Error?) -> Void) { guard let email = email, let password = password else { completion(nil) return } Auth.auth().createUser(withEmail: email, password: password) { authResult, error in completion(error) } } func signIn(email: String?, password: String?, completion: @escaping (Error?) -> Void) { guard let email = email, let password = password else { completion(nil) return } Auth.auth().signIn(withEmail: email, password: password) { authResult, error in completion(error) } } func signOut() { do { try Auth.auth().signOut() } catch(let error) { debugPrint(error.localizedDescription) } } } ContentViewのViewModelを作成して、ログイン中かどうかのフラグを持たせています。 import FirebaseAuth import Combine final class ContentViewModel: ObservableObject { @Published var isLogin: Bool = false private let firebaseAuthService: IFirebaseAuthService // Input: Viewで発生するイベントをViewModelで検知するためのもの let didTapLogoutButton = PassthroughSubject<Void, Never>() // cancellable private var cancellables = Set<AnyCancellable>() init(firebaseAuthService: IFirebaseAuthService) { self.firebaseAuthService = firebaseAuthService firebaseAuthService.addStateDidChangeListener(completion: { [weak self] in self?.isLogin = $0 }) didTapLogoutButton .sink(receiveValue: { firebaseAuthService.signOut() }) .store(in: &cancellables) } } あとはログイン画面や新規登録画面でFirebaseAuthServiceのメソッドを呼ぶだけです ログイン処理 didTapLoginButton .sink(receiveValue: { [weak self] in firebaseAuthService.signIn(email: self?.email, password: self?.password, completion: { if let error = $0 { self?.isShowError = (true, error.localizedDescription) } }) }) .store(in: &cancellables) 新規登録処理 didTapSignUpButton .sink(receiveValue: { [weak self] in firebaseAuthService.signUp(email: self?.email, password: self?.password, completion: { [weak self] in if let error = $0 { self?.isShowError = (true, error.localizedDescription) } }) }) .store(in: &cancellables)
- 投稿日:2021-12-05T12:37:36+09:00
[SwiftUI]クラス・構造体とSwiftUIのコンポーネントの命名が被ってしまった場合
クラス名や構造体の命名が、SwiftUIであらかじめ用意されているViewの命名と被ってしまった場合、 SwiftUI.使いたいViewで指定するで解決。 struct Menu { let label: String let icon: Image } struct MenuView: View { var body: some View { VStack{ SwiftUI.Menu { // SwiftUI.Menuを追記 ForEach(0..<5){ index in Button(action: {}) { Text("index: \(index)") } } } label: { Image(systemName: "plus") } } } } NGな場合 以下のように、構造体やViewプロトコルに準拠させた自作のView構造体を作成する場合、再宣言が無効ですとエラーメッセージが吐き出されるされる。 Invalid redeclaration of 'MenuView' struct MenuView: View { var body: some View { VStack{ Menu { ForEach(0..<5){ index in Button(action: {}) { Text("index: \(index)") } } } label: { Image(systemName: "plus") } } } } struct MenuView: View { var body: some View { Text("menuView") } } しかしながら、以下のような今回の事例では、異なるエラーメッセージが吐き出される struct Menu { let label: String let icon: Image } struct MenuView: View { var body: some View { VStack{ Menu { ForEach(0..<5){ index in Button(action: {}) { Text("index: \(index)") } } } label: { Image(systemName: "plus") } } } } 以下のエラーメッセージが発生する Generic struct 'VStack' requires that 'Menu' conform to 'View' Static method 'buildBlock' requires that 'Menu' conform to 'View' Trailing closure passed to parameter of type 'Decoder' that does not accept a closure Viewプロトコルに準拠させたViewの命名が被った場合は、このメッセージが表示されると思い込んでいたので、構造体同士がかぶっているとは、全く気づかなかった。。。 同じInvalid redeclaration of 'MenuView'で統一して欲しい。。。
- 投稿日:2021-12-05T12:27:30+09:00
【SwiftUI】Button系のlabelをTextにするとメモリリークする問題
追記 日本語か、もしくはアルファベットでも10文字以上だと発生 環境 MacOS Big Sur Xcode 13.1 Swift5 GitHub 現象 struct LoginView: View { @ObservedObject private var viewModel: LoginViewModel init(viewModel: LoginViewModel) { self.viewModel = viewModel } var body: some View { VStack { Text(viewModel.invalidMessage) .foregroundColor(.red) .accessibility(identifier: "loginInvalidMessage") TextField("Eメール", text: $viewModel.email) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .padding() .accessibility(identifier: "loginEmailTextField") SecureField("パスワード", text: $viewModel.password) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .padding() .accessibility(identifier: "loginPasswordSecureField") // これがメモリリークする Button(action: {}) { Text("ログイン") .frame(maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .accessibility(identifier: "loginButton") } // 追記: これもメモリリークする NavigationLink(destination: signUpView) { Text("新規登録") .frame(maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .accessibility(identifier: "toSignUpButton") } } } } 文字を入れるとメモリリークする Button(action: {}) { Text("ログイン") .frame(maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .accessibility(identifier: "loginButton") } 空文字だとメモリリークしない Button(action: {}) { Text("") .frame(maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .accessibility(identifier: "loginButton") } やったこと TextではなくImageを使うとメモリリークしなくなった。 さすがにおかしいので何か知っている方いらっしゃれば教えてください。 追記: 10文字未満のアルファベットを指定するとメモリリークしなくなったのでひとまず日本語を使わなければ大丈夫そう Button(action: {}) { Image(systemName: "hand.thumbsup.fill") } Button(action: { viewModel.didTapLoginButton.send() }) { Text("Login") .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .opacity(viewModel.isLoginEnabled ? 1 : 0.5) .accessibility(identifier: "loginButton") }.disabled(!viewModel.isLoginEnabled)
- 投稿日:2021-12-05T12:27:30+09:00
【SwiftUI】ButtonのlabelをTextにするとメモリリークする問題
現象 struct LoginView: View { @ObservedObject private var viewModel: LoginViewModel init(viewModel: LoginViewModel) { self.viewModel = viewModel } var body: some View { VStack { Text(viewModel.invalidMessage) .foregroundColor(.red) .accessibility(identifier: "loginInvalidMessage") TextField("Eメール", text: $viewModel.email) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .padding() .accessibility(identifier: "loginEmailTextField") SecureField("パスワード", text: $viewModel.password) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .padding() .accessibility(identifier: "loginPasswordSecureField") // これがメモリリークする Button(action: {}) { Text("ログイン") .frame(maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .accessibility(identifier: "loginButton") } } } } 文字を入れるとメモリリークする Button(action: {}) { Text("ログイン") .frame(maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .accessibility(identifier: "loginButton") } 空文字だとメモリリークしない Button(action: {}) { Text("") .frame(maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .accessibility(identifier: "loginButton") } やったこと TextではなくImageを使うとメモリリークしなくなった さすがにおかしいので何か知っている方いらっしゃれば教えてください。 Button(action: {}) { Image(systemName: "hand.thumbsup.fill") }
- 投稿日:2021-12-05T11:06:10+09:00
TensorFlowLiteのモデルをiOSでつかう【機械学習】
iOSでtfliteモデルが使えたら便利ですよね。 基本的には、 CocoaPodsでTensorFlowLiteを追加して、 あとはInterpreterがモデルの初期化、画像から入力テンソルの作成、推論、をクラスメソッドでしてくれます。 TensorFlowのexampleプロジェクトのモデル推論に必要な部分を抜粋・解説した内容です。 (コードは公式のものほとんどそのまま) 基本手順 TensorFlowLiteをインポート Cocoa PodでTensorFlowLitePodを追加(pod install)します。 use_frameworks! pod 'TensorFlowLiteSwift' import TensorFlowLiteSwift TensorFlowLiteモデルをXcodeプロジェクトにドロップしてバンドルします。 ラベルを使う場合はラベルファイルもドロップしてバンドルします。 モデルの初期化 guard let modelPath = Bundle.main.path(forResource: "mobilenet_quant_v1_224", ofType: "tflite") else { print("Failed to load the model."); return nil } var options = InterpreterOptions() options.threadCount = 1 do { // Interpreter(通訳者)として初期化 interpreter = try Interpreter(modelPath: modelPath, options: options) // 入力テンソルのためにメモリを割り当てる try interpreter.allocateTensors() } catch let error { print("Failed to create the interpreter with error: \(error.localizedDescription)") return nil } クラス・ラベルもStringの配列として読み込んでおきます。 guard let fileURL = Bundle.main.url(forResource: "labels", withExtension: "txt") else { fatalError("Labels file not found in bundle. Please add a labels.") } do { let contents = try String(contentsOf: fileURL, encoding: .utf8) self.labels = contents.components(separatedBy: .newlines) } catch { fatalError("Labels file cannot be read.") } 入力の準備 モデルの入力フォーマットに合わせたCVPixelBufferを入力します。 // モデルのパラメーター例 let batchSize = 1 let inputChannels = 3 let inputWidth = 224 let inputHeight = 224 TensorFlow公式のesampleプロジェクトのPixelBuffer変換メソッドを使うために、kCMPixelFormat_32BGRA形式の設定にします。 videoDataOutput.videoSettings = [ String(kCVPixelBufferPixelFormatTypeKey) : kCMPixelFormat_32BGRA] PixelBufferを正方形にクロップします。 extension CVPixelBuffer { /** Returns thumbnail by cropping pixel buffer to biggest square and scaling the cropped image to model dimensions. */ func centerThumbnail(ofSize size: CGSize ) -> CVPixelBuffer? { let imageWidth = CVPixelBufferGetWidth(self) let imageHeight = CVPixelBufferGetHeight(self) let pixelBufferType = CVPixelBufferGetPixelFormatType(self) assert(pixelBufferType == kCVPixelFormatType_32BGRA) let inputImageRowBytes = CVPixelBufferGetBytesPerRow(self) let imageChannels = 4 let thumbnailSize = min(imageWidth, imageHeight) CVPixelBufferLockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) var originX = 0 var originY = 0 if imageWidth > imageHeight { originX = (imageWidth - imageHeight) / 2 } else { originY = (imageHeight - imageWidth) / 2 } // Finds the biggest square in the pixel buffer and advances rows based on it. guard let inputBaseAddress = CVPixelBufferGetBaseAddress(self)?.advanced( by: originY * inputImageRowBytes + originX * imageChannels) else { return nil } // Gets vImage Buffer from input image var inputVImageBuffer = vImage_Buffer( data: inputBaseAddress, height: UInt(thumbnailSize), width: UInt(thumbnailSize), rowBytes: inputImageRowBytes) let thumbnailRowBytes = Int(size.width) * imageChannels guard let thumbnailBytes = malloc(Int(size.height) * thumbnailRowBytes) else { return nil } // Allocates a vImage buffer for thumbnail image. var thumbnailVImageBuffer = vImage_Buffer(data: thumbnailBytes, height: UInt(size.height), width: UInt(size.width), rowBytes: thumbnailRowBytes) // Performs the scale operation on input image buffer and stores it in thumbnail image buffer. let scaleError = vImageScale_ARGB8888(&inputVImageBuffer, &thumbnailVImageBuffer, nil, vImage_Flags(0)) CVPixelBufferUnlockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) guard scaleError == kvImageNoError else { return nil } let releaseCallBack: CVPixelBufferReleaseBytesCallback = {mutablePointer, pointer in if let pointer = pointer { free(UnsafeMutableRawPointer(mutating: pointer)) } } var thumbnailPixelBuffer: CVPixelBuffer? // Converts the thumbnail vImage buffer to CVPixelBuffer let conversionStatus = CVPixelBufferCreateWithBytes( nil, Int(size.width), Int(size.height), pixelBufferType, thumbnailBytes, thumbnailRowBytes, releaseCallBack, nil, nil, &thumbnailPixelBuffer) guard conversionStatus == kCVReturnSuccess else { free(thumbnailBytes) return nil } return thumbnailPixelBuffer } static func buffer(from image: UIImage) -> CVPixelBuffer? { let attrs = [ kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue, kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue ] as CFDictionary var pixelBuffer: CVPixelBuffer? let status = CVPixelBufferCreate(kCFAllocatorDefault, Int(image.size.width), Int(image.size.height), kCVPixelFormatType_32BGRA, attrs, &pixelBuffer) guard let buffer = pixelBuffer, status == kCVReturnSuccess else { return nil } CVPixelBufferLockBaseAddress(buffer, []) defer { CVPixelBufferUnlockBaseAddress(buffer, []) } let pixelData = CVPixelBufferGetBaseAddress(buffer) let rgbColorSpace = CGColorSpaceCreateDeviceRGB() guard let context = CGContext(data: pixelData, width: Int(image.size.width), height: Int(image.size.height), bitsPerComponent: 8, bytesPerRow: CVPixelBufferGetBytesPerRow(buffer), space: rgbColorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return nil } context.translateBy(x: 0, y: image.size.height) context.scaleBy(x: 1.0, y: -1.0) UIGraphicsPushContext(context) image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) UIGraphicsPopContext() return pixelBuffer } } PixelBufferを3チャネルにします(VImageで)。TensorFlow公式のexampleプロジェクトからの引用です。 import Accelerate ... private func rgbDataFromBuffer( _ buffer: CVPixelBuffer, byteCount: Int, isModelQuantized: Bool ) -> Data? { CVPixelBufferLockBaseAddress(buffer, .readOnly) defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } guard let sourceData = CVPixelBufferGetBaseAddress(buffer) else { return nil } let width = CVPixelBufferGetWidth(buffer) let height = CVPixelBufferGetHeight(buffer) let sourceBytesPerRow = CVPixelBufferGetBytesPerRow(buffer) let destinationChannelCount = 3 let destinationBytesPerRow = destinationChannelCount * width var sourceBuffer = vImage_Buffer(data: sourceData, height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: sourceBytesPerRow) guard let destinationData = malloc(height * destinationBytesPerRow) else { print("Error: out of memory") return nil } defer { free(destinationData) } var destinationBuffer = vImage_Buffer(data: destinationData, height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: destinationBytesPerRow) let pixelBufferFormat = CVPixelBufferGetPixelFormatType(buffer) switch (pixelBufferFormat) { case kCVPixelFormatType_32BGRA: vImageConvert_BGRA8888toRGB888(&sourceBuffer, &destinationBuffer, UInt32(kvImageNoFlags)) case kCVPixelFormatType_32ARGB: vImageConvert_ARGB8888toRGB888(&sourceBuffer, &destinationBuffer, UInt32(kvImageNoFlags)) case kCVPixelFormatType_32RGBA: vImageConvert_RGBA8888toRGB888(&sourceBuffer, &destinationBuffer, UInt32(kvImageNoFlags)) default: // Unknown pixel format. return nil } let byteData = Data(bytes: destinationBuffer.data, count: destinationBuffer.rowBytes * height) if isModelQuantized { return byteData } // Not quantized, convert to floats let bytes = Array<UInt8>(unsafeData: byteData)! var floats = [Float]() for i in 0..<bytes.count { floats.append(Float(bytes[i]) / 255.0) } return Data(copyingBufferOf: floats) } 上記メソッドのためのextension extension Data { /// Creates a new buffer by copying the buffer pointer of the given array. /// /// - Warning: The given array's element type `T` must be trivial in that it can be copied bit /// for bit with no indirection or reference-counting operations; otherwise, reinterpreting /// data from the resulting buffer has undefined behavior. /// - Parameter array: An array with elements of type `T`. init<T>(copyingBufferOf array: [T]) { self = array.withUnsafeBufferPointer(Data.init) } } extension Array { /// Creates a new array from the bytes of the given unsafe data. /// /// - Warning: The array's `Element` type must be trivial in that it can be copied bit for bit /// with no indirection or reference-counting operations; otherwise, copying the raw bytes in /// the `unsafeData`'s buffer to a new array returns an unsafe copy. /// - Note: Returns `nil` if `unsafeData.count` is not a multiple of /// `MemoryLayout<Element>.stride`. /// - Parameter unsafeData: The data containing the bytes to turn into an array. init?(unsafeData: Data) { guard unsafeData.count % MemoryLayout<Element>.stride == 0 else { return nil } #if swift(>=5.0) self = unsafeData.withUnsafeBytes { .init($0.bindMemory(to: Element.self)) } #else self = unsafeData.withUnsafeBytes { .init(UnsafeBufferPointer<Element>( start: $0, count: unsafeData.count / MemoryLayout<Element>.stride )) } #endif // swift(>=5.0) } } 推論 推論実行します。 let outputTensor: Tensor do { let inputTensor = try interpreter.input(at: 0) // PixelBufferを3チャネルのDataに guard let rgbData = rgbDataFromBuffer( pixelBuffer, byteCount: batchSize * inputWidth * inputHeight * inputChannels, isModelQuantized: inputTensor.dataType == .uInt8 ) else { print("Failed to convert the image buffer to RGB data."); return nil } // Data を Tensorに. try interpreter.copy(rgbData, toInputAt: 0) // 推論実行 try interpreter.invoke() // 出力 outputTensor = try interpreter.output(at: 0) } catch let error { print("Failed to invoke the interpreter with error: \(error.localizedDescription)") ;return } 出力の取得 出力がuInt8だったら、Floatに直します。 let results: [Float] switch outputTensor.dataType { case .uInt8: guard let quantization = outputTensor.quantizationParameters else { print("No results returned because the quantization values for the output tensor are nil.") return } let quantizedResults = [UInt8](outputTensor.data) results = quantizedResults.map { quantization.scale * Float(Int($0) - quantization.zeroPoint) } case .float32: results = [Float32](unsafeData: outputTensor.data) ?? [] default: print("Output tensor data type \(outputTensor.dataType) is unsupported for this example app.") return } 結果はFloatの配列です。 今回の画像認識の場合、クラスラベル全てについての信頼度として返ってきます。 たとえば、1000クラスの場合は、1000個のFloatです。 // ラベル番号と信頼度のtupleの配列を作る [(labelIndex: Int, confidence: Float)] let zippedResults = zip(labels.indices, results) // 信頼度の高い順に並べ替え、上位一個の個数取得 let sortedResults = zippedResults.sorted { $0.1 > $1.1 }.prefix(1) let label = labels[sortedResults[0].0] let confidence = sortedResults[0].1 print("label:\(label)\nconfidence:\(confidence)") } label:spotlight confidence:0.84118 GitHubサンプル TensorFlowのexampleプロジェクトのモデル推論に必要な部分を抜粋・解説した内容です。 (コードは公式のものほとんどそのまま) ? フリーランスエンジニアです。 お仕事のご相談こちらまで rockyshikoku@gmail.com Core MLやARKitを使ったアプリを作っています。 機械学習/AR関連の情報を発信しています。 Twitter Medium
- 投稿日:2021-12-05T08:41:51+09:00
ラズパイで Vapor を動かす
はじめに 一年前くらいにラズパイで Vapor を動かすのにチャレンジ(ラズパイにVaporいれたけど失敗した話)していたのですがそのときは動かず。。。今年再チャレンジしたらついに動きました 方法としては Mac でプロジェクト作成をしてラズパイに転送するやり方です。 下記記事に大変お世話になりました https://qiita.com/YutoMizutani/items/ed66461a6150d28b886f 環境 Mac macOS Big Sur バージョン 11.6 Xcode 13.1 Vapor Toolbox 18.3.3 ラズパイ Raspberry Pi 3 Model B Raspbian 10 Swift 5.1.5 調べ方よくわかってないですが下記コマンドで確認しました。 $ lsb_release -a No LSB modules are available. Distributor ID: Raspbian Description: Raspbian GNU/Linux 10 (buster) Release: 10 Codename: buster $ swift --version Swift version 5.1.5 (swift-5.1.5-RELEASE) Target: armv6-unknown-linux-gnueabihf Mac側の操作 Mac 側色々やってたのでちょっと何入れたか覚えてないです。。。たぶん下記コマンドはやったはず。 $ brew tap vapor/tap $ brew install vapor/tap/vapor $ brew install libressl 色々入れたら Vapor プロジェクトを作成します。 プロジェクト作成(いろいろ聞かれるやつは全部 n にしました) $ vapor new hoge Xcode で開く $ cd hoge $ vapor xcode Package.swift などを修正 Package.swift // swift-tools-version:5.1 // ここ変更 import PackageDescription let package = Package( name: "hoge", platforms: [ .macOS(.v10_15) ], dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"), // ここ変更 ], targets: [ .target( name: "App", dependencies: [ .product(name: "Vapor", package: "vapor") ], swiftSettings: [ .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) ] ), .target(name: "Run", dependencies: [.target(name: "App")]) // ここ変更 ] ) main.swift import App import Vapor var config = Config.default() var env = try Environment.detect() var services = Services.default() try configure(&config, &env, &services) let app = try Application(config: config, environment: env, services: services) try app.run() configure.swift import Vapor public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws { let router = EngineRouter.default() try routes(router) services.register(router, as: Router.self) } routes.swift import Vapor func routes(_ router: Router) throws { router.get { req in return "It works!" } router.get("hello") { req -> String in return "Hello, world!" } } これでとりあえず動くか実行してみます(Xcode で Run する)。 実行すると下記のように表示され、ブラウザで下記 URL にアクセスすると「It works!」の表示が確認できます Server starting on http://localhost:8080 無事起動を確認できたので停止して Xcode を閉じます。 ラズパイへ転送する前に不要そうな下記ファイルを削除しておきます。 .dockerignore .git .gitignore docker-compose.yml Dockerfile Tests .swiftpm/xcode/xcuserdata こんな状態です。 下記コマンドでラズパイに転送します。Mac 側はこれで終了! $ scp -r hoge pi@raspberrypi.local: ラズパイ側 ラズパイの OS インストールとかは下記参考。 MacでラズパイのLチカしてみた 下記のように設定しておいてやるとディスプレイなしでいけました $ cd /Volumes/boot $ vi wpa_supplicant.conf 下記を記載して wq で終了。 ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev update_config=1 country=JP network={ ssid="SSID記載F" psk="パスワード記載" key_mgmt=WPA-PSK } ラズパイを起動したらあとは必要そうなものを入れて Mac から転送したやつを実行すれば OK です。 // いろいろ更新 $ sudo apt-get update $ sudo apt-get upgrade // Swift インストール $ curl -s https://packagecloud.io/install/repositories/swift-arm/release/script.deb.sh | sudo bash $ sudo apt-get install swift5 // Vapor に必要なやつインストール $ sudo apt-get install libssl-dev // Mac から転送したやつ実行 $ cd hoge $ swift run Run 無事起動できました おわりに ほんとはラズパイだけでやりたかったんですが Ubuntu とか Docker とかがどうしてもよくわからず。。。まあラズパイで Vapor を動かすことができたのでよかったです あとは Mac のブラウザからアクセスできるよう模索したいと思います(http://pi@raspberrypi.local:8080 とかでアクセスできると思ったんですが無理でした)。 参考 SwiftのみでIoT! Raspberry Pi Zeroでサーボを動かすサーバーを立ててiPhoneから制御する Vaporで始めるサーバーサイドSwift 〜Mac上での環境構築からHello, World!まで〜 (swiftenv, Vapor Toolboxを使用)