0.はじめに
この記事はNTTコノキュー アドベントカレンダー2025の記事です。
こんにちは、NTTコノキューの山本です。普段はAndroid エンジニアとして開発させていただいております。
今回は私のプロジェクトでAndroid XR(Jetpack XR)を用いたデモアプリ開発を行うことになったので、そちらについてお話しさせていただければと思います。
前後編に分けますので、前編の今回はHomeSpaceモードの実装までについてお話しさせていただきます。
1.アプリ概要
敢えて文字だけで要件を書いて行こうかなぁと
みなさんもどんな物を作れば良いのか想像してみてください。
【目的(ユースケース)】
移動中など、何処でも学習を行うことができる
実際の物を見ながら手順書/マニュアルを確認して作業ができる
→3Dモデルで実物のモデルを見ながら学習ができ、実物が無くても実際の手順などの予習ができる
【要件】
学習用教材はPDFファイルで確認できる
学習用コンテンツ(教材/3Dモデル)はアプリのビルドを行うこと無く差し替えることができる
教材のページ毎に対応するアニメーションへ切り替える事ができる
*前編では要件No.1&2の一部を対応していきます
2.開発環境
MacBook Apple M4
Android Studio Narwhal 4 Feature Drop | 2025.1.4 Canary 2
Jetpack XR SDK 1.0.0-alpha07
Jetpack XR SDKが結構変更が入って尚且つ結構デカい影響の改修を入れてくるので
チームで取り組まれる場合はメンバー間でバージョン合わせる事に注意してください。
3.実装手順
前編の記事ではHomeSpaceモードでの実装をご紹介します。
UI自体はごく普通のものなのですが、PdfRenderという物を利用したので
今回はそれについてご紹介しようと思います。
参考)
3.1 PdfRenderについて
3.11 PDFを読み込む
以下のopen()を使ってあげる事でPDFファイルを開く事ができます。
suspend fun open(context: Context, uri: Uri) {
withContext(Dispatchers.IO) {
close()
parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")
pdfRenderer = parcelFileDescriptor?.let { PdfRenderer(it) }
}
}
この時、open()に渡してあげるUriですが、content://形式にしてあげる必要があります。
恐らくfilePicker等を使って端末内から取得するのを想定しているのだと思います。
今回、PDFファイルはサーバから取得する処理にしたいのでfile://からcontent://へ変換してあげる必要があります。(3.2項にて後述)
今回はLaunchEffectを使って画面を開いた時かUriが変化した時に呼び出す様にしました。
LaunchedEffect(Unit, uri) {
coroutineScope.launch {
try {
renderScope.open(context, uri)
viewModel.setPageCount(renderScope.getPageCount())
val initialBitmap = renderScope.renderPage(0)
viewModel.setBitmap(initialBitmap)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
ページのカウント
fun setPageCount(pageCount: Int) {
_pageCount.value = pageCount
}
open()した後、ページ数や最初のページのbitmapをViewModel内に保存しています。
ページ数は以下の様な感じで簡単に取ってくることができます。
fun getPageCount(): Int = pdfRenderer?.pageCount ?: 0
3.12 PDFをBitmapにする
仕組みとしては3.12項で読み込んだPDFのページ数を指定してBitmapとして返して貰って、それをアプリ内で描画する感じです。
suspend fun renderPage(pageIndex: Int): Bitmap? {
return withContext(Dispatchers.IO) {
pdfRenderer?.let { renderer ->
if (pageIndex < 0 || pageIndex >= renderer.pageCount) return@let null
// ページを開く
val page = renderer.openPage(pageIndex)
// Bitmapを作成してページ内容を描画
val bitmap = createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
// ページを閉じる
page.close()
bitmap
}
}
}
以下の様な感じでViewModelに呼び出し用の関数を作って貰って
fun renderPage(rendererScope: PdfRenderScope, pageIndex: Int) {
viewModelScope.launch {
_bitmap.value = rendererScope.renderPage(pageIndex)
}
}
ページ送りボタンのonClick時等のタイミングで呼び出してあげます
//次のページボタン
IconButton(onClick = {
val newPage = currentPage + 1
if (newPage < pageCount) {
viewModel.setCurrentPage(newPage)
viewModel.renderPage(renderScope, newPage)
}
})
アプリ内では1つのbitmapを使用するので
「このPDFファイルの○ページ目をbitmapにして」と言ったようなイメージです。
これでページ送りの機能を再現しています。
参考)
val bitmap = createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
今回作ったアプリでは実装していないのですが
結局はただのBitmapなので、この辺りをいじってあげれば初期サイズを変更できますし
拡大/縮小の様な操作も実装可能です。
3.2 PDFをFirebaseから取得する
さて、今回表示するためのPDFを外から取ってくる様にしたいので
表示するコンテンツの取得元としてFirebase Cloud Storageを選定しました。
SDKを使って簡単に取ってこれるので大変便利です。
3.2.1 認証を行う
どうやってもセキュアな作りになるのが素晴らしいところで
AppCheckとFirebase Authによる認証が必要になります。
今回のアプリでは起動時に認証を走らせる様にしました。
認証方法はあまりこだわる必要も無かったのでBasic認証で
AppCheck
Firebase.appCheck.installAppCheckProviderFactory(
DebugAppCheckProviderFactory.getInstance(),
)
ログイン
auth.signInWithEmailAndPassword(YOUR_ID, YOUR_PASSWORD)
.addOnCompleteListener(this) { task ->
if (task.isSuccessful) {
Firebase.initialize(context = this)
} else {
CustomLogger.d("Error")
}
}
signInWithEmailAndPassword()にIDとパスワードを渡してあげれば良いので
適当なログイン画面をこさえてあげれば簡単にログイン画面を作ることができます。
3.22 取得しアプリで使える様にする
取得自体はすごく簡単です。
override suspend fun getPDFData(fileName: String): Result {
try {
val fileRef = storage.reference.child("pdf/$fileName")
val localFile = File.createTempFile("pdf_", ".pdf")
fileRef.getFile(localFile).await()
cachedPDFFiles[fileName] = localFile
return Result.Success(localFile)
} catch (e: Exception) {
return Result.Error(e)
}
}
ポイントとしてはPdfRenderがcontent://の形しか受け付けてくれないので
変換のために一旦キャッシュしておく必要がある点でしょうか
val authResult = authRepository.ensureSignedIn()
if (authResult is Result.Error) {
_uiState.value =
PdfUiState.Error("${authResult.exception?.message}")
return@launch
}
ViewModelから呼び出す前に一旦認証を確認して貰って
when (val result = pdfRepository.getPDFData(fileName)) {
is Result.Success -> {
val pdfFile = result.data
try {
val pdfUri = FileProvider.getUriForFile(
context,
YOUR_PROVIDER,
pdfFile
)
_pdfUri.value = pdfUri
_uiState.value = PdfUiState.Success
} catch (e: Exception) {
_uiState.value = PdfUiState.Error("${e.message}")
}
}
取ってきたファイルをFileProviderを使ってcontent://に変換しViewModel内に保存します。
< provider
android:name="androidx.core.content.FileProvider"
android:authorities="{YOUR_APPID}.provider"
android:exported="false"
android:grantUriPermissions="true">
< meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource=YOUR_FILE_PATH />
< /provider>
FileProviderを使うのでxmlファイルとManifestへの記載はお忘れ無く
3.3 表示する
表示する・・・と見出しを書きましたがImage()にBitmapを渡してあげるだけです。
これにてHomeSpaceモード部分は完成になります。

4.おわりに
今回はHomeSpaceモードでのアプリ開発について紹介させていただきました。
見ていただけた様に、Jetpack XRを使えば既存のAndroidアプリ開発と大して変わらずに
開発が可能であるという点が魅力的かと思います。
みなさんも是非チャレンジしてみてください。