NTTドコモR&Dの技術ブログです。

Android XRグラス向けの学習アプリの開発をしてみた 後編

※ 本記事は 2026/3/31 以前にNTTコノキューにて記載した記事になります

0.はじめに

この記事はNTTコノキュー アドベントカレンダー2025の記事です。

こんにちは、NTTコノキューの山本です。

今回は前回ご紹介した私のチームで開発したAndroid XR(Jetpack XR)向けのデモアプリの後編

(FullSpaceモード編)についてご紹介させていただきます。

開発環境などは前回と同じなので、前回の記事をご参考ください。

1.大まかな画面を作る

FullSpaceモードなのでSpatialPanelを使って画面を作っていく必要があります。

今回は「PDFを表示する部分」「ページ送りナビゲーション」「Orbiter」「3Dコンテンツ表示部(Volume)」で作成する事にしました。


SpatialPanel(
    modifier = SubspaceModifier.weight(0.8f),
    dragPolicy = MovePolicy(),
    resizePolicy = ResizePolicy(),
) 

割と最近、SpatialPanelを自由に移動できる様にするmovable()と自由にサイズを変える事ができる様になるresizable()が非推奨となりました。

これらはMovePolicy()ResizePolicy()でそれぞれ代替可能なのでご注意ください。

(GitHub Copilotから古い方で提案されていたので、油断してると紛れ込むかも・・・?)

今回HomeSpaceとFullSpaceで大きく部材は変えていないので、大まかな画面の作りとしては以上となります。

空間要素はSubSpace{}の中に入れてあげて、通常のComposableはSpatialの中で呼んであげる。

これだけのルールを守れば良い点が個人的にJetpack XRの魅力だなぁと思います。

2.3DモデルをFirebaseから取得する

実はPDFの方はその様な作りになっていないのですが、3Dモデルの方は開発の段階で結構差し替える事が多かったので、自由に入れ替えできる様な作りにしています。

今回サーバから取得する情報としては以下3点です。

  1. 使用するモデルのファイル名
  2. 使用するモデルのアニメーション名
  3. 実際のモデルのglbファイル

1と2についてはただのStringなのですが、3については前回のPDFファイルの様なファイル形式になります。

ですので、1と2をFirebase Realtime Database、3をFirebase Cloud Storageから取得する。という作りにしました。

2.1 Firebaseからモデルの情報を取得する

2.11 モデル名を取得する

こちらも公式からありがたいドキュメントがあるので細かいところはそちらをご参考にしていただければと思います。

モデル名については今回RESTでやりたかったのでRESTで取ってくる様にしてみました。


@GET(YOUR_FILE_PATH) 
suspend fun getModel(
    @Query("auth") authToken: String
): ModelContent


data class ModelContent(
    val name: String? = null,
)

上記の様にデータクラスなどを定義してあげて


override suspend fun getModel(
    authToken: String
): Result {
    try {
        val api = pdfAppContainer.getModelNameApi()
        return Result.Success(api.getModel(authToken = authToken))
    } catch (e: Exception) {
        return Result.Error(e)
    }
}

前回PDFファイルを取ってきた様に実行してあげればOKです。

違いとしては認証がauthTokenなところでしょうか。

authTokenはgoogle-services.jsonの中に記載されているのですが、これが漏れてしまうと当然知らない人からAPIが叩かれ放題になってしまうので流出に注意です。


fun getModelName() {
    _uiState.value = PdfUiState.Loading
    viewModelScope.launch {
        val result = pdfRepository.getModel(
            authToken = YOUR_AUTH_TOKEN
        ) 
        when (result) {
            is Result.Success -> {
                _uiState.value = PdfUiState.Success
                if (result.data.name == null) {
                    CustomLogger.d("Model name is null")
                    return@launch
                }
                _modelName.value = result.data.name
            }

あとはViewModelに呼び出し様のロジックを記載して貰って


LaunchedEffect(Unit) {
    viewModel.getModelName()
}

ご所望のタイミングで呼び出してあげればOKです。

このモデル名はglbファイルを取得する際に使用するのでViewModelあたりに残しておいてください。

2.12 アニメーション名を取得する

@GET(YOUR_FILE_PATH) 
suspend fun getAnimation(
    @Query("auth") authToken: String
): ModelAnimation


data class ModelAnimation(
    val first: String? = null,
    val second: String? = null,
    val third: String? = null,
)

やってる事はモデル名の取得と全く同じです。

(今回はちょっと面倒だったので3つまで設定できる様にしました)

ここで設定するアニメーション名としてはモデルのglbに設定されている再生したいアニメーションを設定してください。

先の話ですが、アニメーションを再生する際、1から順にというよりアニメーション名を指定して再生するので順番は特に影響ないです。

fun getAnimation() { uiState.value = PdfUiState.Loading viewModelScope.launch { val result = pdfRepository.getAnimation( authToken = NetworkConfig.AUTH_TOKEN ) when (result) { is Result.Success -> { uiState.value = PdfUiState.Success if (result.data.first == null) return@launch _animationList.value = listOfNotNull(result.data.first, result.data.second, result.data.third) }

これもあとで使うのでViewModelに入れておいてください。

2.13 3Dモデルを取得する

作りとしては前回のPDFファイルの取得と全く同じです。(詳しくはそちらをご参考に)

ファイルのパスとして2.11で取得してきたモデルのファイル名を使用します。

これで3Dモデルをアプリビルド後に好き放題替えられる様になりました。


when (val result =
    pdfRepository.getModelData(context, fileName)) { 
    is Result.Success -> {
        val glbFile = result.data
        try {
            val fileBytes = glbFile.readBytes()
            _glbUri.value = fileBytes
            _uiState.value = PdfUiState.Success
        } catch (e: Exception) {
            _uiState.value = PdfUiState.Error("read error")
            CustomLogger.d("Error : ${e.message}")
        }
    }

違う点としては、ファイルはreadBytes()してもらう必要がある点でしょうか。

(3Dモデル読込時にこの形式である必要があります)

2.2 3Dモデルの表示を行う

2.2.1 モデルの表示をする

3Dモデルの表示については、まずGltfModelのcreate()で読み込みを行ったあと、GltfModelEntitiyを使って表示させます。

公式のドキュメントとしてはアプリに3Dモデルを追加するの項目に記載があります。


val session = checkNotNull(LocalSession.current) //忘れずに
val model = GltfModel.create(session = session, assetData = glbUri, assetKey = "model")

このcreate()にはいろいろと種類があって


public suspend fun create(
    session: Session,
    assetData: ByteArray,
    assetKey: String,
)

今回はbyte array dataを使った物を使用しました。

assetDataには先ほど取得してbyte array dataに変換したglbファイルを使用します。

assetKeyについては任意の文字列で大丈夫です。


val entity = GltfModelEntity.create(session, model)
animationList.value[0].let {
    entity.startAnimation(animationName = it, loop = true)
}
parent.addChild(entity)

startAnimation()に一番最初に表示させたいアニメーションを設定します。

あとはこれをVolume()内に記載し、所望のタイミングで呼び出してもらえればOKです。

2.2.2 ページ送りでアニメーションを切り替える

val animationList = viewModel.animationList.collectAsState()
val currentPage by viewModel.currentPage.collectAsState()
val modelEntity by modelEntityState.collectAsState()

LaunchedEffect(currentPage) {
    val entity = modelEntity ?: return@LaunchedEffect
    if (animationList.value.size <= currentPage || animationList.value.isEmpty()) return@LaunchedEffect
    entity.startAnimation(loop = true, animationName = animationList.value[currentPage])
}

LaunchEffectでページ数が切り替わった際に設定するanimationNameを変更する作りにしました。

動画が貼れないので分かりにくくなっちゃってるのですが、ページ送りでアニメーションが切り替わる様になりました。

これにて完成です!

おわりに

今回前後編に分けてJetpack XRを用いたアプリ開発についてご紹介させていただきました。

今回実装して感じたのは、「表示するモデルを外部から取ってきて表示させる」といった機能が比較的楽に実装できる様になっているという点を大変魅力に感じました。

Jetpack Composeを利用された事のある方だとかなりとっつきやすい物になっているので

是非チャレンジしてみてください。