Uzzu::Blog

Software Design, and my life.

UnityのPlay Asset DeliveryをtargetSdk34に対応させる

本記事はクラスター Advent Calendar 2023 7日目の記事です。

前日は @tsgcpp さんの 【VRM, glTF】3Dアバターファイルフォーマット “VRM” の構造をのぞいてみよう でした。VRM完全に理解した。


Androidアプリはアプリサイズが150MBを超えてしまうとPlay Storeにアプリをアップロードすることができなくなってしまいます。この150MBの制限について、ページによっては上限は200MBに緩和されたと書かれていたりして何が正しいのか判断が難しいですが、150MBを閾値に対応しても損はないでしょう。

この課題に対してPlay Asset Deliveryを利用することで、アプリからアセット切り離してアプリサイズを削減する事ができます。 切り離したアセットはアプリ側でPlay Asset Deliveryに対応する形でアセットを読み込むよう実装しておくことで、install-time/fast-follow/on-demandのいずれかのタイミングでダウンロードして利用することができます。

Play Asset DeliveryはUnityでも使用することができますが、targetSdkを34(Android 14相当)にすると問題が発生します。

本記事では、targetSdk=34にする事を諦めずにPlay Asset Deliveryを利用する方法について解説します。 なお、本記事はあくまでも「AAR分からん」「JAR分からん」「JVM bytecode分からん」の学習がてら読んでいただく事を目的としています。 公式の対応を待つべきですし、実際の対応として参考にされる方は自己責任でお願いします。


動作の変更点: Android 14 以上をターゲットとするアプリ に記載の通り、Android 14をターゲットとする(targetSdk 34)アプリでは、実行時にBroadcastReceiverを登録する場合、BroadcastReceiverをデバイスの他のすべてのアプリに対してexportするかどうかを示すフラグを指定する必要があります。 実際に、この修正に未対応なバージョンのUnityでPlay Asset Deliveryを使用してみるとクラッシュします。stacktraceは以下の通りです。

java_vm_ext.cc:591] JNI DETECTED ERROR IN APPLICATION: JNI NewGlobalRef called with pending exception java.lang.SecurityException: co.uzzu.uwaao: One of RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED should be specified when a receiver isn't being registered exclusively for system broadcasts
(中略)
java_vm_ext.cc:591]   at void com.google.android.play.core.listener.b.b() ((null):-1)
java_vm_ext.cc:591]   at void com.google.android.play.core.listener.b.f(com.google.android.play.core.listener.StateUpdatedListener) ((null):-1)
java_vm_ext.cc:591]   at void com.google.android.play.core.assetpacks.i.registerListener(com.google.android.play.core.assetpacks.AssetPackStateUpdateListener) ((null):-1)

この問題に対するPlay Asset Delivery側の修正は2.1.0に入っています。 https://developer.android.com/reference/com/google/android/play/core/release-notes-asset_delivery#2-1-0

そこで試しに、Unityが依存している com.google.android.play:core:1.10.0 を同ライブラリに置き換えてアプリをビルド・実行してみましょう。 同じくクラッシュします。

Abort message: 'JNI DETECTED ERROR IN APPLICATION: JNI NewStringUTF called with pending exception java.lang.NoSuchMethodError: No interface method getPackStates(Ljava/util/List;)Lcom/google/android/play/core/tasks/Task; in class Lcom/google/android/play/core/assetpacks/AssetPackManager; or its super classes (declaration of 'com.google.android.play.core.assetpacks.AssetPackManager' appears in (省略)
  at void com.unity3d.player.a.a(java.lang.String[], com.unity3d.player.IAssetPackManagerStatusQueryCallback) ((null):-1)
  at void com.unity3d.player.PlayAssetDeliveryUnityWrapper.getAssetPackStates(java.lang.String[], com.unity3d.player.IAssetPackManagerStatusQueryCallback) ((null):-1)
  at void com.unity3d.player.PlayAssetDeliveryUnityWrapper.getAssetPackState(java.lang.String, com.unity3d.player.IAssetPackManagerStatusQueryCallback) ((null):-1)
  at boolean com.unity3d.player.UnityPlayer.nativeRender() ((null):-2)
  at boolean com.unity3d.player.UnityPlayer.access$300(com.unity3d.player.UnityPlayer) ((null):-1)
  at boolean com.unity3d.player.UnityPlayer$e$1.handleMessage(android.os.Message) ((null):-1)
  at void android.os.Handler.dispatchMessage(android.os.Message) (Handler.java:102)
  at boolean android.os.Looper.loopOnce(android.os.Looper, long, int) (Looper.java:205)
  at void android.os.Looper.loop() (Looper.java:294)
  at void com.unity3d.player.UnityPlayer$e.run() ((null):-1)

Unityが依存しているPlay Asset Deliveryのバージョンは Migration from the Play Core Java and Kotlin Library に記載の通りマイグレーション対象となっており、単純に依存ライブラリを変更しても、Unityが内部的に呼び出そうとしているPlay Asset Deliveryのメソッドのシグネチャがあっていないためにクラッシュしています。

さて困りました。選択肢を考えてみましょう。

  • A ) Play Asset Deliveryの利用を諦める
    • 代わりにAssetBundle + Addressable対応の道も当然あるが、UnityでPlay Asset Deliveryを使用するという事は、Androidアプリのサイズにのみ問題があるのでは?
      • つまり使いたいユースケースはinstall-timeぐらい
    • 諦めたくない
  • B ) targetSdkを34に上げるのを諦める
    • (説明が長くなる為省略するが) 普通のAndroidアプリ開発が難しくなる
    • 諦めたくない
  • C ) Unityが依存しているPlay Asset DeliveryライブラリをtargetSdk 34対応させる
    • Feature Delivery, In-App Review, In-App Updateライブラリについても古いバージョンに依存し続ける事になる
    • (A) (B) の選択肢よりはのめる
    • (D) よりは容易に実現できる
  • D ) libunity.so を書き換える
    • (C) よりむずい。2023年にやる事なのか…?

というわけで、(C) をやっていきます。

まずは、手元にPlay Asset DeliveryのAAR(ライブラリ本体)をGoogle Maven Repositoryから直接ダウンロードします。

ダウンロードできたら、新ライブラリでどのように対応されたか確認してみます。AARもJAR(classes.jar)もZIPファイルなのでunzipしつつこちらの記事を参考に 該当箇所のJVM Instructionsを覗いてみると、以下のようになっています。

// javap -verbose -private com.google.android.play.core.assetpacks.internal.n
39: getstatic     #123                // Field android/os/Build$VERSION.SDK_INT:I
42: bipush        33
44: if_icmplt     107
47: aload_0
48: getfield      #44                 // Field d:Landroid/content/Context;
51: aload_0
52: getfield      #31                 // Field e:Lcom/google/android/play/core/assetpacks/internal/m;
55: aload_0
56: getfield      #37                 // Field c:Landroid/content/IntentFilter;
59: iconst_2
60: invokevirtual #129                // Method android/content/Context.registerReceiver:(Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;I)Landroid/content/Intent;
63: pop

最初の3行をみると SDK_INT >= 33 か否かで分岐していることがわかります。 加えて、3行目は 44: if_icmlt 107 とあるので、 SDK_INT >= 33 ではなかった場合のジャンプ先のInstructionも見てみます。

107: aload_0
108: getfield      #44                 // Field d:Landroid/content/Context;
111: aload_0
112: getfield      #31                 // Field e:Lcom/google/android/play/core/assetpacks/internal/m;
115: aload_0
116: getfield      #37                 // Field c:Landroid/content/IntentFilter;
119: invokevirtual #136                // Method android/content/Context.registerReceiver:(Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;)Landroid/content/Intent;
122: pop
123: goto          64

以上の内容から、旧ライブラリの該当箇所のInstructionが以下のようになるよう修正すれば良さそうです。

if (Build.VERSION.SDK_INT >= 33) {
    // export指定を引数に含めてContext#registerRecieverを呼び出す
} else {
    // 修正対応前に同じく、export指定なしでContext#registerRecieverを呼び出す
}

というわけで旧ライブラリを修正していきましょう。修正には Apache Commons BCEL™︎を使用します。 他にもASMJavassistByteBuddyなどの選択肢もありますが、1回きりの対応になるのでなんでもいいと思います。

対応を始める前に、これから行うことを整理しておきます。

  1. 旧ライブラリのAARをZipFileとして読み込む
  2. unzipしたAARに含まれるclasses.jarをJarFileとして読み込む
  3. (今回の対応のために、不足しているクラスやメソッド参照のための定義を) ConstatntPoolに追加する
  4. クラッシュが発生しているメソッドの該当のInstructionを削除する
  5. 新ライブラリと同等となるようにInstructionを追加する
  6. クラッシュが発生しているメソッド定義を修正したものに置き換える
  7. メソッド定義を置き換えた .class ファイルを生成して、classes.jar内の該当クラスを修正したものに置き換える
  8. classes.jarを置き換えてAAR(zip)を保存する

というわけで順番にやっていきましょう。使用する言語はKotlinです。Kotlinは良い言語ですね。

なお、これ以降に掲載するコードは以下の定義が存在する前提のコードになっています。
private const val ClassesJarPrefix = "classes"
private const val ClassesJarSuffix = ".jar"
private const val ClassesJarFileName = "$ClassesJarPrefix$ClassesJarSuffix"
private const val ListenerClassName = "com/google/android/play/core/listener/b.class"
private val ListenerFuncPredicate: (Method) -> Boolean = {
    it.isPrivate &&
        it.isFinal &&
        it.name == "b" &&
        it.signature == "()V"
}
private fun <T> File.useAsZipFile(block: ZipFile.() -> T): T =
    ZipFile(this).use {
        block(it)
    }
private fun ZipFile.classesJarEntry(): ZipEntry =
    checkNotNull(
        entries().asSequence().find { entry -> entry.name == "classes.jar" }
    )
private fun <T> File.useAsJarFile(block: JarFile.() -> T): T =
    JarFile(this).use {
        block(it)
    }
private fun JarFile.listenerClass(): JavaClass =
    getInputStream(listenerClassEntry()).use {
        val parser = ClassParser(it, ListenerClassName)
        parser.parse()
    }
private fun JavaClass.findListenerFunc(): Method =
    checkNotNull(
        methods.find(ListenerFuncPredicate)
    )

手順1, 2はサクサクいきます。

input.useAsZipFile {
    val classesJarEntry = classesJarEntry()
    val classesJarFile = getInputStream(classesJarEntry).use { input ->
        val tempFile = File.createTempFile("tmp", ".jar")
        FileOutputStream(tempFile).use { output ->
            output.write(input.readBytes())
        }
        tempFile
    }
    classesJarFile.useAsJarFile {
        // 後々書き換えるので、良い感じに該当クラスを抽出しておく
        val listenerClass = listenerClass()
        val listenerFunc = listenerClass.findListenerFunc()
        val listenerClassGen = ClassGen(listenerClass)
        val constantPoolGen = listenerClassGen.constantPool
        val listenerFuncGen = MethodGen(listenerFunc, listenerClassGen.className, constantPoolGen)

        // :
        // : 手順3へ
        // :
    }
}

次に手順3です。

.classファイルには、静的な文字列や外部のクラスのフィールドやメソッドを参照するためのConstantPoolというものが含まれています。 ここに含まれていない外部のクラスを呼び出す事はできませんし、それ以前に.classファイルを生成する事もできません。

旧ライブラリの実装を覗いてみると、今回呼び出す android.os.Build や 予定のexport引数付きの Context#registerReciever のメソッドのシグネチャがCommonPoolに含まれていません。 ちまちまと追加していきます。Apache BCEL™︎ではInstructionを追加しながらConstantPoolへの追加も行うことができますが、解説都合上手前で追加していきます。

// classesJarFile.useAsJarFile {
    // 手順2の続き

    // いずれもpublic static classなので AccessFlag を使い回す
    val accessFlagRaw = Const.ACC_PUBLIC or Const.ACC_STATIC

    // Context#registerReceiver
    val registerReceiverNameAndTypeIndex = constantPoolGen.addNameAndType("registerReceiver", "(Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;I)Landroid/content/Intent;")
    val registerReceiverIndex = constantPoolGen.addMethodref("android/content/Context", "registerReceiver", "(Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;I)Landroid/content/Intent;")

    // android.os.Build
    val innerClassesNameIndex = constantPoolGen.addUtf8("InnerClasses")
    val buildIndex = constantPoolGen.addClass("android/os/Build")

    // android.os.Build.VERSION.SDK_INT
    val versionIndex = constantPoolGen.addClass("android/os/Build\$VERSION")
    val versionNameIndex = constantPoolGen.addUtf8("VERSION")
    val sdkIntIndex = constantPoolGen.addFieldref("android/os/Build\$VERSION", "SDK_INT", "I")
    val versionInnerClass = InnerClass(versionIndex, buildIndex, versionNameIndex, accessFlagRaw.toInt())

    // android.os.Build.VERSION は android.os.Build のinner classなので、InnerClassの属性も追加
    val innerClassArray = arrayOf(versionInnerClass)
    val innerClassesAttribute = InnerClasses(innerClassesNameIndex, 2 + 8 * innerClassArray.size, innerClassArray, constantPoolGen.constantPool)
    listenerClassGen.addAttribute(innerClassesAttribute)

    // :
    // : 手順4へ
    // :
// }

次に手順4ですが、先に旧ライブラリのInstructionを確認しておきましょう。 新ライブラリのInstructionを確認した時のように、旧ライブラリのInstructionを確認してみます。

// javap -private -verbose com.google.android.play.core.listener.b
40: aload_0
41: getfield      #46                 // Field d:Landroid/content/Context;
44: aload_0
45: getfield      #33                 // Field e:Lcom/google/android/play/core/listener/a;
48: aload_0
49: getfield      #39                 // Field c:Landroid/content/IntentFilter;
52: invokevirtual #126                // Method android/content/Context.registerReceiver:(Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;)Landroid/content/Intent;
55: pop

新ライブラリの Build.VERSION.SDK_INT >= 33 でなかった時の処理とほぼ同じなので、修正対応の分岐においても同じInstructionを実行すれば良さそうですね。 確認が取れたので、該当のInstructionを削除します

// classesJarFile.useAsJarFile {
    // 手順3の続き

    // InstructionListの数値(18, 25, 26とか) がよくわからないとなったら、とりあえずprintlnなどしてみましょう
    // listenerFuncGen.instructionList.forEachIndexed { index, handle ->
    //     println("$index: ${handle.position} ${handle.instruction}")
    // }
    val originalInstructionList = listenerFuncGen.instructionList.toList()
    val deleteFrom = originalInstructionList[18] // aload_0[42](1)
    val deleteTo = originalInstructionList[25] // pop[87]
    val next = originalInstructionList[26] // aload_0[42](1) (この手前にInstructionを挿入するので、次のInstructionを覚えておく)
    listenerFuncGen.instructionList.delete(deleteFrom, deleteTo)

    // :
    // : 手順5へ
    // :
// }

手順5です。

if_icmplt の挿入時にジャンプ先のInstructionを知っておく必要があるので、先にelse句を追加しています。 事前に調べた新ライブラリと旧ライブラリのInstructionと照らし合わせながら見ていきましょう。

// classesJarFile.useAsJarFile {
    // 手順4の続き

    // 手順4の時に調べたInstructionを追加
    val elseHandle = listenerFuncGen.instructionList.append(ALOAD(0))
    listenerFuncGen.instructionList.append(GETFIELD(46))
    listenerFuncGen.instructionList.append(ALOAD(0))
    listenerFuncGen.instructionList.append(GETFIELD(33))
    listenerFuncGen.instructionList.append(ALOAD(0))
    listenerFuncGen.instructionList.append(GETFIELD(39))
    listenerFuncGen.instructionList.append(INVOKEVIRTUAL(126))
    listenerFuncGen.instructionList.append(POP())

    // 分岐元に戻る命令を追加
    listenerFuncGen.instructionList.append(GOTO(next))

    // Instructionを挿入するので、Instructionのpositionを不確定な状態にする
    listenerFuncGen.instructionList.setPositions(false)

    // 分岐とexport指定でのContext#registerReceiverの呼び出しを行うInstructionを挿入
    val instructionFactory = InstructionFactory(listenerClassGen)
    listenerFuncGen.instructionList.insert(next, instructionFactory.createGetStatic("android/os/Build\$VERSION", "SDK_INT", Type.INT))
    listenerFuncGen.instructionList.insert(next, BIPUSH(33.toByte()))
    listenerFuncGen.instructionList.insert(next, IF_ICMPLT(elseHandle)) // 先に追加したelseのInstructionHandleを指定してelseに遷移できるようにする
    listenerFuncGen.instructionList.insert(next, ALOAD(0))
    listenerFuncGen.instructionList.insert(next, GETFIELD(46))
    listenerFuncGen.instructionList.insert(next, ALOAD(0))
    listenerFuncGen.instructionList.insert(next, GETFIELD(33))
    listenerFuncGen.instructionList.insert(next, ALOAD(0))
    listenerFuncGen.instructionList.insert(next, GETFIELD(39))
    listenerFuncGen.instructionList.insert(next, ICONST(2))
    listenerFuncGen.instructionList.insert(next, INVOKEVIRTUAL(registerReceiverIndex))
    listenerFuncGen.instructionList.insert(next, POP())

    // 全てのInstructionを挿入し終えたのでpositionを確定する
    listenerFuncGen.instructionList.setPositions(true)

    // Instructionの編集を行ったら、最後にメソッドの定義として正しい状態になるように諸々再計算する
    listenerFuncGen.setMaxStack()
    listenerFuncGen.setMaxLocals()

    // :
    // 手順6へ
    // :
// }

手順6です。 現在クラス内にある修正対象のメソッドの定義をそのまま上書きはできないようなので、削除して追加します。

// classesJarFile.useAsJarFile {
    // 手順5の続き

    listenerClassGen.removeMethod(listenerFunc)
    listenerClassGen.addMethod(listenerFuncGen.method)

    // :
    // 手順7へ
    // :
// }

手順7です。 Jarの定義を直接編集する事はできないので、新たに生成しながら既存のJarのEntryを追加しつつ、該当クラスのみ新しいものに置き換えていきます。

// classesJarFile.useAsJarFile {
    // 手順6の続き

    // .classを生成するためのJavaClassを生成
    val modifiedListenerClass = listenerClassGen.javaClass
    modifiedListenerClass.fileName = ListenerClassName

    JarOutputStream(FileOutputStream(outputJar)).use { outputStream ->
        entries().asSequence().forEach { entry ->
            if (entry.name == ListenerClassName) {
                // 該当の.classは新しいJavaClassを使用して置き換え
                val jarEntry = JarEntry(ListenerClassName)
                outputStream.putNextEntry(jarEntry)
                val bytes = ByteArrayOutputStream().use {
                    modifiedListenerClass.dump(it)
                    it.toByteArray()
                }
                outputStream.write(bytes)
            } else {
                // 既存のEntryはそのまま追加
                outputStream.putNextEntry(entry)
                getInputStream(entry).use { inputStream ->
                    outputStream.write(inputStream.readBytes())
                }
            }
            outputStream.closeEntry()
        }
    }
// }

// :
// 手順8へ
// :

手順8です。 手順7とほぼ変わりません。該当クラスの置き換えがclasses.jarの置き換えになっただけです。

// input.useAsZipFile {
    // 手順7の続き

    ZipOutputStream(FileOutputStream(output)).use { outputStream ->
        entries().asSequence().forEach {  entry ->
            if (entry.name == ClassesJarFileName) {
                // classes.jarは新しいものに置き換え
                val jarEntry = JarEntry(ClassesJarFileName)
                outputStream.putNextEntry(jarEntry)
                val bytes = outputJar.readBytes()
                outputStream.write(bytes)
            } else {
                // 他のファイルはそのまま追加
                outputStream.putNextEntry(entry)
                getInputStream(entry).use { inputStream ->
                    outputStream.write(inputStream.readBytes())
                }
            }
            outputStream.closeEntry()
        }
    }
// }

できました!全てのソースコードはこちらにあります。 あとはPlay Asset Deliveryを使用する際に、参照するライブラリを今回修正対応を行ったAARに置き換えてビルドすれば、Unity側の修正を待たずしてtargetSdk=34でもPlay Asset Deliveryを動かす事ができます。 よかったですね。


というわけで「UnityのPlay Asset DeliveryをtargetSdk34に対応させる」でした。

クラスター Advent Calendar 2023の明日の記事は@YOSHIOKA_Ko57さんの、「デザインタスクの切り方を変えてみた話」 です。

comments powered by Disqus