酢ろぐ!

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

Parallels Desktop 18 for Mac を導入した

Parallels Desktop 18 for Macが、ARM版の「Windows 11 Pro」と「Windows 11 Enterprise」の動くソリューションとして正式に Microsoft による認証を受けたと話題になっていたのでこの機会に導入してみた。

7年ぶりの MacBook Proで Parallels Desktop である。当時は Visual Studio 2013 で C#を使ってサーバーサイドの実装をしながら、Xcode で iOSアプリの開発を……といったように Windows と Mac の二刀流だったが、現職への転職とともに Windows を使う機会がゼロになってしまった。

Parallels Desktop はアップデート価格で買おう

Parallels Desktop 18 for Mac の定価では12,800円だが、過去に Parallels Desktop を利用したことがあればアップグレードで 7,800円で購入できる。

Apple も大昔には MacBook Pro と Parallels Desktop を抱き合わせで売っていた時期があり、昔からのMBPユーザーであればほとんどの人がアップグレード価格で買えるのではないかと思う。

Parallels Desktop 18 for Macを初回起動したら、いきなり Windows 11 のインストールが開始した。なかには Linux 等を使いたい人もいるのではないかもと思ったけれどそういうものなんだろうか。

たまたま Windows 10 Pro のプロダクトキーがひとつ余っていたので入力したところ、Windows 11 Pro のアクティベートができた。Windows 8 のプロダクトキーもたくさん余っていた気がするけれどどこにいってしまったんだろうか……

ARM版Windowsでゲームは動くのか?

ARM版Windowsでゲームは動くのか検証してみた。

「魔物娘と不思議な冒険」の最初のダンジョンをクリアするところまで遊んだ。ARM版Windowsでも問題なく動くことが確認できたので「魔物娘と不思議な冒険2」を買っても良いかもしれない。

「ドラゴンファングZ」は、Parallels Desktop for Macが悪いのか、ARM版Windowsが悪いのかわからないが、上下が見切れる不具合がある。ローグライク系で HPや制限時間などの重要な情報が見られないという最悪な問題が発生してた

「虫姫さま」は特に問題なくクリアまで動作確認ができた。

「Fortnite」は起動しなかった。一番期待していたのでとても残念。WindowsでFortniteを遊ぶ分には GPD WIN 2を使わないといけなさそうだ。

CoreML 向けの mlmodel モデルで物体検出/物体識別が可能なラベルの一覧を取得する

CoreMLでは画像識別/オブジェクト検出をおこなう機械学習モデルは .mlmodel または .mlpackage ファイルである。モデルファイルをダブルクリックするとXcodeが開き、下図のようにメタデータが表示される。

ここに表示されている Class Labels が検出または識別可能なオブジェクトの一覧であるが一括でコピペすることができない。

テキストファイルで Class Labelsの一覧が欲しいので 100個までは手作業で入力あるいは一行ずつコピペしたが、画像識別モデルで有名な MobileNet v2 はラベルが1,000個もありおおよそ現実的ではないと判断して、モデルのメタデータを参照する方法を調べた。

mlmodel ファイルで検出または識別可能なラベルの一覧を取得する

任意の mlmodel ファイルにおいて、オブジェクト検出/画像識別が可能なラベルの一覧を取得する。

if let mlModel = try? MobileNetV2FP16(configuration: MLModelConfiguration()).model {
    let classLabels = mlModel.modelDescription.classLabels
    
    print("### \(mlModel.configuration.modelDisplayName!)\n")
    classLabels?.forEach({ label in
        print("\(label)")
    })
    print("\n")
}

上記のサンプルコードを実行すると以下のリストが得られる。

### MobileNetV2FP16

background
tench, Tinca tinca
goldfish, Carassius auratus
great white shark, white shark, man-eater, man-eating shark, Carcharodon carcharias
tiger shark, Galeocerdo cuvieri
hammerhead, hammerhead shark
... 以下略

Flutter で Riverpod を利用してデバイス名やデバイスのOSバージョンを取得する

Flutter でデバイス名やデバイスのOSバージョンを取得するためには device_info_plusapple_product_name を利用する。riverpod での利用を想定しており、本記事で紹介するサンプルコードでは FutureProvider を使っている 。

device_info_plus では iOSおよびmacOSの場合は iPhone15,2 などとモデル名を取得するため、パッとみてわかる一般名称に変換する apple_product_name をセットで導入している。ちなみに iPhone15,2 とは iPhone 14 Pro のモデル名である。

モデル名から一般名称に変換するパッケージは多数あるが、メンテナーが途中で更新を止めるケースが多く、数日前に更新のあった apple_product_name を使うことにした。

なお、Windows / Web / Linux に関しては調査できる環境がないため対象外としている。

デバイス名を取得する

デバイス名の取得には device_info_plus を利用している。前述のようにAppleデバイスはモデル名と商品名が異なるため apple_product_name を使って一般的な名称に変換するようにしている。apple_product_name の変換リストにモデル名がなかった場合例外を吐くためその対策もしている。

/// デバイスモデル
final deviceModelProvider = FutureProvider((ref) async {
  final deviceInfoPlugin = DeviceInfoPlugin();
  if (Platform.isAndroid) {
    final info = await deviceInfoPlugin.androidInfo;
    return info.model;
  } else if (Platform.isIOS) {
    final info = await deviceInfoPlugin.iosInfo;
    return info.utsname.productNameOrNull ?? info.utsname.machine;
  } else if (Platform.isMacOS) {
    final info = await deviceInfoPlugin.macOsInfo;
    return info.productNameOrNull ?? info.model;
  } else {
    return "unknown";
  }
});

apple_product_name を使いたくない場合は、device_info_plusにて取得した値をそのまま使えば返すようにすればよい。自前で変換リストを作るのもそんなに手間ではないと思う。

/// デバイスモデル
final deviceModelProvider = FutureProvider((ref) async {
  final deviceInfoPlugin = DeviceInfoPlugin();
  if (Platform.isAndroid) {
    final info = await deviceInfoPlugin.androidInfo;
    return info.model;
  } else if (Platform.isIOS) {
    final info = await deviceInfoPlugin.iosInfo;
    return info.utsname.machine;
  } else if (Platform.isMacOS) {
    final info = await deviceInfoPlugin.macOsInfo;
    return info.model;
  } else {
    return "unknown";
  }
});

ただしこの手のデータはメンテするのがものすごく大変なので素直に外部ライブラリを使わせてもらった方が良いだろう。

デバイスのOSバージョンを取得する

デバイスのOSバージョンも device_info_plus から取得できる。

/// デバイスのOSバージョン
final systemVersionProvider = FutureProvider((ref) async {
  final deviceInfoPlugin = DeviceInfoPlugin();
  if (Platform.isAndroid) {
    final info = await deviceInfoPlugin.androidInfo;
    return "Android ${info.version.release}";
  } else if (Platform.isIOS) {
    final info = await deviceInfoPlugin.iosInfo;
    return "${info.systemName} ${info.systemVersion}";
  } else if (Platform.isMacOS) {
    final info = await deviceInfoPlugin.macOsInfo;
    return "macOS ${info.osRelease}";
  } else {
    return "unknown";
  }
});

アプリバージョンを取得する

アプリ情報の取得には package_info_plus を使う。

/// アプリバージョン
final packageVersionProvider = FutureProvider((ref) async {
  final info = await PackageInfo.fromPlatform();
  return "${info.version} (${info.buildNumber})";
});

実行結果

上記の「デバイス名」「OSバージョン」「アプリバージョン」をどのように表示させるのか簡単なサンプルを紹介する。riverpod を使っている。

class SettingsScreen extends ConsumerWidget {
  const SettingsScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final packageVersion = ref.watch(packageVersionProvider).value ?? "unknown";
    final systemVersion = ref.watch(systemVersionProvider).value ?? "unknown";
    final deviceModel = ref.watch(deviceModelProvider).value ?? "unknown";

    return Scaffold(
      appBar: AppBar(
        title: const Text("タイトル"),
      ),
      body: Center(
        child: Column(
          children: [
            Text("アプリバージョン:$packageVersion"),
            Text("OSのバージョン:$systemVersion"),
            Text("デバイスモデル:$deviceModel"),
          ],
        ),
      ),
    );
  }
}

Android、iPhone 14 Pro、MacBook Pro での実行結果を下図に示す。

実行結果:左より Android、iPhone 14 Pro、MacBook Pro である。

Flutter for macOSで firebase_core と flutter_secure_storage をインストールしたときにビルドに失敗してしまう

FlutterでFirebaseを使うために firebase_core および flutter_secure_storage を導入したところ、ビルドエラーが発生してしまうようになった。iOSでは問題なくビルドできているが macOSの場合に問題が発生するようだ。

pub.dev

pub.dev

firebase_core を導入するとビルドエラーが発生

firebase_core を導入すると下記のビルドエラーが発生しまう。

Analyzing dependencies

Inspecting targets to integrate
  Using `ARCHS` setting to build architectures of target `Pods-Runner`: (``)

Fetching external sources
-> Fetching podspec for `FlutterMacOS` from `Flutter/ephemeral`
-> Fetching podspec for `firebase_core` from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`
firebase_core: Using Firebase SDK version '10.3.0' defined in 'firebase_core'
[!] Failed to load 'firebase_core' podspec: 
[!] Invalid `firebase_core.podspec` file: [!] The FlutterFire plugin firebase_core for macOS requires a macOS deployment target of 10.12 or later..

このエラーを解決するためには Podfile の記述を変更する必要がある。macos/Podfile の記述はデフォルトでは 10.11 となっている。これを 10.13 に変更する。

#platform :osx, '10.11'
platform :osx, '10.13'

flutter_secure_storage を導入するとビルドエラーが発生

flutter_secure_storage を導入すると下記のビルドエラーが発生しまう。

/Users/ch3cooh/works/blocknote_ios/peacemaker/macos/Flutter/GeneratedPluginRegistrant.swift:9:8: error: compiling for macOS 10.11, but module 'flutter_secure_storage_macos' has a minimum deployment target of macOS 10.13: /Users/ch3cooh/works/blocknote_ios/peacemaker/build/macos/Build/Products/Debug/flutter_secure_storage_macos/flutter_secure_storage_macos.framework/Modules/flutter_secure_storage_macos.swiftmodule/arm64-apple-macos.swiftmodule
import flutter_secure_storage_macos
       ^
/Users/ch3cooh/works/blocknote_ios/peacemaker/macos/Flutter/GeneratedPluginRegistrant.swift:9:8: error: compiling for macOS 10.11, but module 'flutter_secure_storage_macos' has a minimum deployment target of macOS 10.13: /Users/ch3cooh/works/blocknote_ios/peacemaker/build/macos/Build/Products/Debug/flutter_secure_storage_macos/flutter_secure_storage_macos.framework/Modules/flutter_secure_storage_macos.swiftmodule/arm64-apple-macos.swiftmodule
import flutter_secure_storage_macos
       ^

このエラーを解決するためには deployment target を 10.13 以上に上げる必要がある。macos/Runner.xcworkspaceを開くと deployment target がデフォルトでは 10.11 となっていることがわかる。これを 10.13 に変更する。

Flutter for macOSアプリで twitter_login パッケージを使った場合にTwitterログインが失敗する

開発には MacBook Proを使っているが、Flutter でのデバッグ実行で最速なのは「macOS (desktop)」だと思う。そのため画面遷移のテストなどは macOSアプリで動作確認している。

現在、Twitter APIを利用するアプリを開発しているが、煩雑なログイン処理のため twitter_login を利用している。Twitter API を oauth1 で利用したい場合には唯一無二のライブラリパッケージである。欲を言えば Flutter Web*1でも使いたいのだが現在のところ非対応である。対応が難しいのかもしれない。

pub.dev

macOSアプリでTwitterのログインに失敗する問題が発生し、何が問題なのか気付くのに時間がかかったため備忘録を残しておく。

(結論) twitter_login を macOSで使うために必要なもの

twitter_login を macOSで使うためには以下の前準備が必要である。どれかが抜けていると後述するエラーが発生する。

  • macOS の Capabilities のうち「Outgoing Connections (Client)」にチェックをつけておく
  • Twitter Developer Portal で アプリアイコン、アプリの説明、Callback URI、Website URLを入力しておく

順番に説明していく。

macOSアプリはネットワーク利用の権限がないとWeb API にアクセスできない。macos/Runner/DebugProfile.entitlementsmacos/Runner/Release.entitlementscom.apple.security.network.client を追加しておく必要がある。

<key>com.apple.security.network.client</key>
<true/>

慣れていれば 直接 .entitlements ファイルを書き換えても良いが、Xcodeを開いて Capabilities から下図の通りチェックボタンを入れる方が確実だろう。

https://developer.twitter.com/en/portal/dashboard にアクセスして API Key と API Secret Key を準備しておく。「まだ自分しか使わないから……」という理由でアプリアイコンとアプリの説明 を入力してなかったが、後述のエラーが発生した。

以上で、macOSアプリで twitter_login が使えるようになっているはずだ。この結論の章だけを読んでおけば、ここから先の文章は読む必要がない。

macOSアプリにネットワーク権限がない場合、twitter_login のログイン処理が失敗する

ここから先は失敗編である。

macOSアプリで twitter_login を使ったところ、PlatformExceptionが発生した。

実際には下位レイヤーからどんな例外が飛んできても、ログイン時に発生した例外は PlatformException: Please check your APIKey or APISecret. でまとめられてしまうようだった。

  Future<AuthResult> login({bool forceLogin = false}) async {
    String? resultURI;
    RequestToken requestToken;
    try {
      requestToken = await RequestToken.getRequestToken(
        apiKey,
        apiSecretKey,
        redirectURI,
        forceLogin,
      );
    } on Exception {
      throw PlatformException(
        code: "400",
        message: "Failed to generate request token.",
        details: "Please check your APIKey or APISecret.",
      );
    }

実際に発生しているエラーは SocketException: Connection failed (OS Error: Operation not permitted, errno = 1), address = api.twitter.com, port = 443 であった。

dart - How to enable Flutter internet permission for macos desktop app? - Stack Overflow」によれば、macOSアプリでネットワークごしにアクセスする場合には com.apple.security.network.client の権限が必要であることがわかった。

Twitter Developer Portal のアプリ設定でアイコンを設定していないと twitter_login のログイン処理が失敗する

Twitter Developer Portal のアプリ設定でアプリアイコン画像を設定していなかったところ HttpException: Failed Unauthorized が発生した。

1年ほど前まではアプリアイコン画像を設定していなくてもエラーにならなかったと思うがいつの間にか仕様が変わったのかもしれない。

*1:Flutter on the Web

KMMライブラリをiOSプロジェクトから利用する

Kotlin Multiplatform Mobile (以下KMM)を使ってアプリを開発したいと考えている。

将来的には Jetpack Compose を使って UI を実装すれば、Flutter のように共通のコードで iOS側でも実行できるようになるようだが現時点では難しそうだ。Android Studio 上で iOSアプリを開発するのも IDE の支援を受けられず厳しい。少なくとも私には無理だ。iOSプロジェクトの開発には Xcode を使いたい。

そこでアプローチを変えることを考えた。そこで Kotlin Multiplatform Mobile Library (以下KMMライブラリ)を使ってビジネスロジックを実装して shared.xcframework を出力する。iOSアプリではその shared.xcframework を import して利用できるかを検証していく。

KMMライブラリのプロジェクトを作成する

スケルトンをそのまま使う。ポチポチしていく。

できました。

KMMライブラリの構造について

KMMライブラリは下図の構造となっている。

.
├── androidMain
│   └── kotlin
│       └── jp
│           └── ch3cooh
│               └── kmmsaple
│                   └── Platform.kt
├── commonMain
│   └── kotlin
│       └── jp
│           └── ch3cooh
│               └── kmmsaple
│                   ├── Greeting.kt
│                   └── Platform.kt
└── iosMain
    └── kotlin
        └── jp
            └── ch3cooh
                └── kmmsaple
                    └── Platform.kt

iOS/Android共通の処理を commonMain に実装して、各プラットフォームごとの処理を androidMain および iosMain に実装する。commonMain で一本化できるのが理想だろうが、このスケルトンライブラリのようにプラットフォームを取得するのに iOS固有の UIDevice を使わないといけなかったりする。

commonMain/Greeting.kt

アプリ側からは Greetingクラスを使うことになる。アプリ側でGreeting().greet() を実行すれば Hello, iOS 16.2! と文字列が返ってくる。

class Greeting {
    private val platform: Platform = getPlatform()

    fun greet(): String {
        return "Hello, ${platform.name}!"
    }
}

commonMain/Platform.kt

Platform は interface である。androidMain と iosMain にてプラットフォーム固有の実装をおこなう。

interface Platform {
    val name: String
}

expect fun getPlatform(): Platform

iosMain/Platform.kt

iosMain/Platform.kt には iOS側の実装を書く。アプリが動いている環境を取得するのに UIDevice を使っているのがわかる。

class IOSPlatform: Platform {
    override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}

actual fun getPlatform(): Platform = IOSPlatform()

KMMライブラリを .xcframework として出力する

Android Studio の Terminal で ./gradlew assembleXCFramework を実行すると、shared.xcframework が出力される。

$ ./gradlew assembleXCFramework

> Task :shared:assembleSharedDebugXCFramework
xcframework successfully written out to: MY_PATH/build/XCFrameworks/debug/shared.xcframework

> Task :shared:assembleSharedReleaseXCFramework
xcframework successfully written out to: MY_PATH/build/XCFrameworks/release/shared.xcframework

iOS プロジェクトから KMM ライブラリを参照する

Xcodeで任意のスケルトンアプリプロジェクトを作成する。

frameworks ディレクトリを作成して、さきほど出力した shared.xcframework をコピーする。

Xcode のプロジェクト設定の Frameworks, Libraries, and Embedded Content から shared.xcframework を参照する。

ViewController.swift

KMMライブラリで生成した文字列をUILabel に表示させる。画面表示時に呼ばれる viewDidLoad() にて Greeting().greet() を実行させる。

import UIKit
import shared

class ViewController: UIViewController {

    @IBOutlet weak var label: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        label.text = Greeting().greet()
    }
}

iOSプロジェクトから KMMライブラリを利用した結果

Xcode で実行した結果は下図の通り。

まとめ

以上のことから Xcode の iOSプロジェクトから KMMライブラリを利用できることがわかった。Android アプリ + KMMライブラリで先行開発し、あとから iOSアプリを開発するケースでは大いに役立ちそうだ。

iOS 16 の場合、SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

iOS 15 / iOS 16 の場合、SwiftUI の List で セクションヘッダーに top padding (top margin?) が入ってしまう問題がある。

ヘッダー上部にマージンが入る現象自体は iOS 15 の時にも発生していたが UITableView.appearance().sectionHeaderTopPadding = 0 で解決できた。iOS 16 からは List の内部実装が UITableView から UICollectionView に変わったようで、iOS 16 ではこの対策は使えなくなった。

この手の問題は、たとえば List から UITableView を取り出す SwiftUI-Introspect などを使えばもっと簡単に解決するのかもしれないが、Apple の都合で内部実装が変わってしまう現状を鑑みると適切な対応ではないだろう。

本記事ではそれっぽい対応策を記載しているが、iOS 15 の場合とは異なり実践では使えないかもしれない。

結論からいえば、過去のデザイン(iOS 14 での表示)に拘らずに、iOSのバージョンごとの SwiftUI でのデフォルト表示に任せるのが一番良いだろう。

iOS 15 で SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

Xcode 13 登場時に話題になった。SwiftUI だけの問題ではなく UITableView への変更だったので UITableViewController を継承した画面にも下記のようなコードをたくさん埋め込んだ。

    override func viewDidLoad() {
        super.viewDidLoad()
//...
        if #available(iOS 15.0, *) {
            tableView.sectionHeaderTopPadding = 0
        }
    }

一方、SwiftUI の List ではどのように対策したかといえば、前述したように UITableView.appearance().sectionHeaderTopPadding = 0 を実行して対策した。

struct ListView: View {
    
    var body: some View {
        List {
            Section {
                Text("🍎")
                Text("🍊")
                Text("🍓")
            } header: {
                Text("Red")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.red)
            }
            Section {
                Text("🥒")
                Text("🥬")
                Text("🫑")
            } header: {
                Text("Green")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.green)
            }
        }
        .navigationTitle("iOS \(UIDevice.current.systemVersion)")
        .navigationBarTitleDisplayMode(.inline)
        .listStyle(.plain)
        .onAppear {
            if #available(iOS 15.0, *) {
                UITableView.appearance().sectionHeaderTopPadding = 0
            }
        }
    }
}

iOS 16 で SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

単に見た目を合わせるだけであれば「How to remove section header top padding in SwiftUI Plain List with iOS16 - Stack Overflow」を参考にして対応することができる。

var layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain)
layoutConfig.headerMode = .supplementary
layoutConfig.headerTopPadding = 0

let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
UICollectionView.appearance().collectionViewLayout = listLayout

コード全体は以下の通り。

struct ListView: View {
    
    var body: some View {
        List {
            Section {
                Text("🍎")
                Text("🍊")
                Text("🍓")
            } header: {
                Text("Red")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.red)
            }
            Section {
                Text("🥒")
                Text("🥬")
                Text("🫑")
            } header: {
                Text("Green")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.green)
            }
        }
        .navigationTitle("iOS \(UIDevice.current.systemVersion)")
        .navigationBarTitleDisplayMode(.inline)
        .listStyle(.plain)
        .onAppear {
            if #available(iOS 16.0, *) {
                var layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain)
                layoutConfig.headerMode = .supplementary
                layoutConfig.headerTopPadding = 0

                let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
                UICollectionView.appearance().collectionViewLayout = listLayout
            } else if #available(iOS 15.0, *) {
                UITableView.appearance().sectionHeaderTopPadding = 0
            }
        }
    }
}

懸念点

ただこの方法はあまり筋がよくない。たとえば SmartNews のような上部タブがあるアプリの場合、上部タブはおそらく UICollectionView を使って実装されている。UICollectionView.appearance() に対しての変更処理をおこなっているので、競合をおこして上部タブの表示が狂ってしまうだろう。