20211126のAndroidに関する記事は6件です。

Navigation SDK v2 for Androidを試してみる (中身を見る編)

はじめに 前回Navigation SDK v2 for Androidのサンプルをコピペで動かしてみました。今回はコードの中を見て、どのような処理が行われているかを追ってみます。 注意事項 この記事では2021/11/25現在で最新のv2.0.2を使用します。 Pricingにご注意ください。特にMAUは100まで無料ですが、ここに記載されているようにアプリケーションの削除・インストールでカウントアップします。デバッグ中に何度も削除・インストールを繰り返すと無料枠を超える可能性があります。 概観 サンプルで使用してる機能の概観は以下のようになります。 コンポーネント 説明 LocationEngine 位置情報のソースとなる実装のインターフェース MapboxNavigation Navigation Coreの中心モジュール。Navigation Nativeへのインターフェース Navigation Native マップマッチングや進捗管理を行う。C++で実装され、iOSと共通 ViewportDataSource マップマッチング済みの位置情報からカメラコントロールに必要なパラメータ(center/zoom/bearing/pitch)を計算 NavigationCamera 実際にカメラをコントロール。アニメーションも担当 LocationProvider Maps SDKで定義されるインターフェース。Maps SDKのLocationComponentで使用 LocationPuck2D Maps SDKで実装される自車位置アイコン。LocationProviderで指定された位置にアイコンを表示 ManeuverApi 次の方向指示を取得 MapboxManeuverView 次の方向指示を表示するView TripProgressApi 進捗状況を取得 MapboxTripProgressView 進捗状況を表示 SpeechApi 音声案内を生成(音声ファイル) VoiceInstructionPlayer 音声を再生 一番左のLocationEngineがフローの起点となります。LocationEngineは位置情報を提供します。通常、デバイスのGPS(SDKのデフォルトではFused Location)がソースとなりますが、LocationEngineを実装することで外部接続のGPSを使用することもできます。サンプルではReplayLocationEngineを用いて経路に沿った位置情報を順次生成しています。 LocationEngineは位置情報をMapboxNavigation経由でNavigation Nativeに渡します。Navigation Nativeは得られた位置情報を元にマップマッチングを行います。また、設定された経路を参照し、現在の進捗も管理します。 マップマッチング済みの位置情報や進捗情報がNavigation NativeからMapboxNavigationに渡され、各種機能に提供されます。 onCreate まず、エントリポイントであるonCreateを覗いてみましょう。主に初期化処理が行われています。 Puckの設定 LocationPuck2Dの設定をしています。これはMaps SDKに対する設定です。 TurnByTurnExperienceActivity.kt 418 // initialize the location puck 419 binding.mapView.location.apply { 420 this.locationPuck = LocationPuck2D( 421 bearingImage = ContextCompat.getDrawable( 422 this@TurnByTurnExperienceActivity, 423 R.drawable.mapbox_navigation_puck_icon 424 ) 425 ) 426 setLocationProvider(navigationLocationProvider) 427 enabled = true 428 } LocationPuck2Dはデフォルトで青いドットを表示しますが、ここではR.drawable.mapbox_navigation_puck_iconをbearingImageに設定することでカーナビっぽいアイコンを表示しています。 setLocationProviderにはNavigation SDKで実装しているNavigationLocationProviderをセットします。 ちなみに、Maps SDKが実装しているLocationProviderImplは直接Fused Locationから位置情報を取得します。Navigation SDKではLocationProviderImplを使用せず、独自のNavigationLocationProviderを用いることでマップマッチングができるようにしています。 Navigation Coreの初期化 ここではMapboxNavigationの初期化を行います。 TurnByTurnExperienceActivity.kt 430 // initialize Mapbox Navigation 431 mapboxNavigation = if (MapboxNavigationProvider.isCreated()) { 432 MapboxNavigationProvider.retrieve() 433 } else { 434 MapboxNavigationProvider.create( 435 NavigationOptions.Builder(this.applicationContext) 436 .accessToken(getString(R.string.mapbox_access_token)) 437 // comment out the location engine setting block to disable simulation 438 .locationEngine(replayLocationEngine) 439 .build() 440 ) 441 } MapboxNavigationProviderは MapboxNavigationをシングルトンとして利用するためのヘルパークラスです。MapboxNavigationを複数作成して使用すると思わぬバグに遭遇するので、必ずMapboxNavigationProvider経由で使用してください。 NavigationOptionsで各種設定を行います。ここではトークンとLocationEngineの設定を行っています。サンプルではReplayLocationEngineを使用しています。ReplayLocationEngineはMapboxReplayerが再生(Play)する緯度・軽度にしたがって位置情報を更新します。シナリオに書かれたとおりに位置情報を更新していくようなイメージです。 カメラの設定の初期化 次にカメラの設定です。 TurnByTurnExperienceActivity.kt 443 // initialize Navigation Camera 444 viewportDataSource = MapboxNavigationViewportDataSource(mapboxMap) 445 navigationCamera = NavigationCamera( 446 mapboxMap, 447 binding.mapView.camera, 448 viewportDataSource 449 ) カメラは視点の移動に利用されます。カメラにはOverviewとFollowingの二種類があります。Overviewは上空からまっすぐ見下ろすカメラです。FollowingはPuckの後方上空から見下ろすようなカメラです。MapboxNavigationViewportDataSourceは両方のカメラのためのパラメータを計算します。NavigationCameraがそのパラメータにしたがってカメラをコントロールします。 カメラコントロールはv2の目玉機能のひとつなので、ぜひ遊んでみてください。 Banner等 方向指示等のイベントのことを"Maneuver"と呼びます。MapboxManeuverApiを初期化しています。 TurnByTurnExperienceActivity.kt 480 // initialize maneuver api that feeds the data to the top banner maneuver view 481 maneuverApi = MapboxManeuverApi( 482 MapboxDistanceFormatter(distanceFormatterOptions) 483 ) さらにMapboxTripProgressApiを初期化しています。 TurnByTurnExperienceActivity.kt 485 // initialize bottom progress view 486 tripProgressApi = MapboxTripProgressApi( 487 TripProgressUpdateFormatter.Builder(this) 488 .distanceRemainingFormatter( 489 DistanceRemainingFormatter(distanceFormatterOptions) 490 ) 491 .timeRemainingFormatter( 492 TimeRemainingFormatter(this) 493 ) 494 .percentRouteTraveledFormatter( 495 PercentDistanceTraveledFormatter() 496 ) 497 .estimatedTimeToArrivalFormatter( 498 EstimatedTimeToArrivalFormatter(this, TimeFormat.NONE_SPECIFIED) 499 ) 500 .build() 501 ) 音声案内の初期化 MapboxSpeechApiおよびMapboxVoiceInstructionsPlayerを初期化しています。 TurnByTurnExperienceActivity.kt 503 // initialize voice instructions api and the voice instruction player 504 speechApi = MapboxSpeechApi( 505 this, 506 getString(R.string.mapbox_access_token), 507 Locale.US.language 508 ) 509 voiceInstructionsPlayer = MapboxVoiceInstructionsPlayer( 510 this, 511 getString(R.string.mapbox_access_token), 512 Locale.US.language 513 ) 経路描画の初期化 経路を描画するための初期化処理です。ここではroad-labelの上に経路を描画するように設定しています。 TurnByTurnExperienceActivity.kt 515 // initialize route line, the withRouteLineBelowLayerId is specified to place 516 // the route line below road labels layer on the map 517 // the value of this option will depend on the style that you are using 518 // and under which layer the route line should be placed on the map layers stack 519 val mapboxRouteLineOptions = MapboxRouteLineOptions.Builder(this) 520 .withRouteLineBelowLayerId("road-label") 521 .build() 522 routeLineApi = MapboxRouteLineApi(mapboxRouteLineOptions) 523 routeLineView = MapboxRouteLineView(mapboxRouteLineOptions) 524 525 // initialize maneuver arrow view to draw arrows on the map 526 val routeArrowOptions = RouteArrowOptions.Builder(this).build() 527 routeArrowView = MapboxRouteArrowView(routeArrowOptions) ナビゲーションの開始 実はonCreateでナビゲーションを開始しています。 TurnByTurnExperienceActivity.kt 560 // start the trip session to being receiving location updates in free drive 561 // and later when a route is set also receiving route progress updates 562 mapboxNavigation.startTripSession() ナビゲーションにはfree-drivingとturn-by-turnの2パターンがあり、経路を設定して開始する一般的なナビゲーションはturn-by-turnナビゲーションです。ここでは経路を設定せずにナビゲーションを開始するため、free-drivingナビゲーションとなります。free-drivingナビゲーションを開始する理由は、マップマッチングを開始するためです。 ここまでが初期化となります。 経路の探索 onCreate内部で、マップのロングタップに対してfindRouteが設定されています。ロングタップした地点の座標が渡されます。 TurnByTurnExperienceActivity.kt 533 // add long click listener that search for a route to the clicked destination 534 binding.mapView.gestures.addOnMapLongClickListener { point -> 535 findRoute(point) 536 true 537 } findRouteの処理は以下のとおりです。 TurnByTurnExperienceActivity.kt 609 private fun findRoute(destination: Point) { ... 620 mapboxNavigation.requestRoutes( 621 RouteOptions.builder() 622 .applyDefaultNavigationOptions() 623 .applyLanguageAndVoiceUnitOptions(this) 624 .coordinatesList(listOf(originPoint, destination)) 625 // provide the bearing for the origin of the request to ensure 626 // that the returned route faces in the direction of the current user movement 627 .bearingsList( 628 listOf( 629 Bearing.builder() 630 .angle(originLocation.bearing.toDouble()) 631 .degrees(45.0) 632 .build(), 633 null 634 ) 635 ) 636 .build(), 637 object : RouterCallback { 638 override fun onRoutesReady( 639 routes: List<DirectionsRoute>, 640 routerOrigin: RouterOrigin 641 ) { 642 setRouteAndStartNavigation(routes) 643 } ... MapboxNavigation#requestRoutesが経路探索を行います。経路探索に関する設定はRouteOptionsです。MapboxNavigationはオンライン時にはDirections APIで、オフライン時にはNavigation Native内部で経路探索を行います。経路情報が得られるとsetRouteAndStartNavigationでturn-by-turnナビゲーションを開始します。 TurnByTurnExperienceActivity.kt 659 private fun setRouteAndStartNavigation(routes: List<DirectionsRoute>) { 660 // set routes, where the first route in the list is the primary route that 661 // will be used for active guidance 662 mapboxNavigation.setRoutes(routes) ... MapboxNavigation#setRoutesで経路を設定することでfree-drivingからturn-by-turnに切り替わります。 位置情報の更新 このサンプルアプリでは設定された経路に沿って位置情報が更新されます(Replay)。具体的には以下のstartSimulationで更新処理が開始します。 TurnByTurnExperienceActivity.kt 659 private fun setRouteAndStartNavigation(routes: List<DirectionsRoute>) { ... 664 // start location simulation along the primary route 665 startSimulation(routes.first()) ... 674 } ... 90 private fun startSimulation(route: DirectionsRoute) { 691 mapboxReplayer.run { ... 697 play() 698 } 699 } ReplayLocationEngineを見てみると、 override fun replayEvents(replayEvents: List<ReplayEventBase>) { replayEvents.forEach { event -> when (event) { is ReplayEventUpdateLocation -> replayLocation(event) } } } ... private fun replayLocation(event: ReplayEventUpdateLocation) { ... registeredCallbacks.forEach { it.onSuccess(locationEngineResult) } ... } MapboxReplayer#playが実行されると定期的にReplayLocationEngine#replayEventsを呼び出し、registeredCallbacksとして登録されているMapboxTripSession#updateRawLocationが実行されます。"raw location"とはGPSから得られた位置情報を示しています(サンプルではReplayで生成された経路上の位置情報です)。 private fun updateRawLocation(rawLocation: Location) { if (state != TripSessionState.STARTED) return this.rawLocation = rawLocation locationObservers.forEach { it.onNewRawLocation(rawLocation) } mainJobController.scope.launch { navigator.updateLocation(rawLocation.toFixLocation()) } } ここで大事なのがnavigator.updateLocation(rawLocation.toFixLocation())です。navigatorは内部的にはNavigation Nativeを指しており、updateLocationにraw locationを渡すことでマップマッチングが行われます。結果はコールバック内でenhancedLocationとして取得できます。 さらにenhancedLocationから作成したLocationMatcherResultをLocationObserverインターフェースを通して上位レイヤに渡します。 private fun updateLocationMatcherResult(locationMatcherResult: LocationMatcherResult) { this.locationMatcherResult = locationMatcherResult locationObservers.forEach { it.onNewLocationMatcherResult(locationMatcherResult) } } ちなみに、サンプルコード内では以下の場所でregisterLocationObserverしています。 TurnByTurnExperienceActivity.kt 565 override fun onStart() { ... 569 mapboxNavigation.registerRoutesObserver(routesObserver) LocationObserversの処理は以下のようになっています。 TurnByTurnExperienceActivity.kt 299 private val locationObserver = object : LocationObserver { 300 var firstLocationUpdateReceived = false 301 302 override fun onNewRawLocation(rawLocation: Location) { 303 // not handled 304 } 305 306 override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) { 307 val enhancedLocation = locationMatcherResult.enhancedLocation 308 // update location puck's position on the map 309 navigationLocationProvider.changePosition( 310 location = enhancedLocation, 311 keyPoints = locationMatcherResult.keyPoints, 312 ) 313 314 // update camera position to account for new location 315 viewportDataSource.onLocationChanged(enhancedLocation) 316 viewportDataSource.evaluate() 317 318 // if this is the first location update the activity has received, 319 // it's best to immediately move the camera to the current user location 320 if (!firstLocationUpdateReceived) { 321 firstLocationUpdateReceived = true 322 navigationCamera.requestNavigationCameraToOverview( 323 stateTransitionOptions = NavigationCameraTransitionOptions.Builder() 324 .maxDuration(0) // instant transition 325 .build() 326 ) 327 } 328 } 329 } onNewLocationMatcherResultの処理内容を見ると、 1) LocationPuck2DのパラメータのnavigationLocationProviderに対してchangePositionを実行します。Maps SDKのコードが実行され、Puck位置が移動します。 TurnByTurnExperienceActivity.kt 309 navigationLocationProvider.changePosition( 310 location = enhancedLocation, 311 keyPoints = locationMatcherResult.keyPoints, 312 ) 2) ViewportDataSourceの位置情報を更新することで、カメラを移動させます。 TurnByTurnExperienceActivity.kt 315 viewportDataSource.onLocationChanged(enhancedLocation) 316 viewportDataSource.evaluate() バナー等の更新 ナビゲーションの更新に必要な情報は下記のObserverで取得します。 TurnByTurnExperienceActivity.kt 565 override fun onStart() { ... 570 mapboxNavigation.registerRouteProgressObserver(routeProgressObserver) ... このObserverはマップマッチング後にここで呼ばれます(enhancedLocationと同じ場所です)。 Observerの処理内容は以下のとおりです。 TurnByTurnExperienceActivity.kt 331 /** 332 * Gets notified with progress along the currently active route. 333 */ 334 private val routeProgressObserver = RouteProgressObserver { routeProgress -> 335 // update the camera position to account for the progressed fragment of the route 336 viewportDataSource.onRouteProgressChanged(routeProgress) 337 viewportDataSource.evaluate() 338 339 // draw the upcoming maneuver arrow on the map 340 val style = mapboxMap.getStyle() 341 if (style != null) { 342 val maneuverArrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress) 343 routeArrowView.renderManeuverUpdate(style, maneuverArrowResult) 344 } 345 346 // update top banner with maneuver instructions 347 val maneuvers = maneuverApi.getManeuvers(routeProgress) 348 maneuvers.fold( 349 { error -> 350 Toast.makeText( 351 this@TurnByTurnExperienceActivity, 352 error.errorMessage, 353 Toast.LENGTH_SHORT 354 ).show() 355 }, 356 { 357 binding.maneuverView.visibility = View.VISIBLE 358 binding.maneuverView.renderManeuvers(maneuvers) 359 } 360 ) 361 362 // update bottom trip progress summary 363 binding.tripProgressView.render( 364 tripProgressApi.getTripProgress(routeProgress) 365 ) 366 } ViewportDataSource ここでもViewportDataSourceを更新していますが今回はonRouteProgressChangedを実行しています。これは主にManeuver周辺でpitchをゼロにして真上から見下ろすカメラワークに使用されます。 ManeuverApi maneuverApi.getManeuversで方向指示を取り出し、binding.maneuverView.renderManeuvers(maneuvers)でManeivuerViewを更新します。 TripProgressApi tripProgressApi.getTripProgress(routeProgress)で進捗情報を取り出し、TripProgressViewを更新します。 音声案内の更新 音声案内の更新に必要な情報は下記のObserverで取得します。 TurnByTurnExperienceActivity.kt 565 override fun onStart() { ... 572 mapboxNavigation.registerVoiceInstructionsObserver(voiceInstructionsObserver) ... このObserverはマップマッチング後にここで呼ばれます(enhancedLocationと同じ場所です)。 Observer内部でSpeechApiが音声を準備し、MapboxVoiceInstructionsPlayerをplayします。 TurnByTurnExperienceActivity.kt 247 /** 248 * Observes when a new voice instruction should be played. 249 */ 250 private val voiceInstructionsObserver = VoiceInstructionsObserver { voiceInstructions -> 251 speechApi.generate(voiceInstructions, speechCallback) 252 } 253 254 /** 255 * Based on whether the synthesized audio file is available, the callback plays the file 256 * or uses the fall back which is played back using the on-device Text-To-Speech engine. 257 */ 258 private val speechCallback = 259 MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>> { expected -> 260 expected.fold( 261 { error -> 262 // play the instruction via fallback text-to-speech engine 263 voiceInstructionsPlayer.play( 264 error.fallback, 265 voiceInstructionsPlayerCallback 266 ) 267 }, 268 { value -> 269 // play the sound file from the external generator 270 voiceInstructionsPlayer.play( 271 value.announcement, 272 voiceInstructionsPlayerCallback 273 ) 274 } 275 ) 276 } まとめ とても長くなりましたが、全体的なフローは以上です。次回はコードを変更しながら動きを見ていきます。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ViewPager2+PdfRendererで簡単なPDFビューアを作る

背景 とある Android アプリで PDF を閲覧する機能に AndroidPdfViewer (jcenter で配布されている) を使っていたのですが、2022年2月に jcenter が使えなくなるので、「その内、MavenCentral なりで publish してくれるだろう」 と呑気に構えていたのですが、README.md の先頭行に Looking for new maintainer! と書かれていたので、これはもう更新されないんだろうなと悟りました。 Android では iOS と違い OS 標準の WebView で PDF 表示とかしてくれません。 以下の記事にあるように、これで苦労したことがある Android プログラマは多いかもしれません。 ただし、Android 5.0 から API (PdfRenderer) が提供されているので、対応 OS が Android 5.0 以降であれば自前で PDF レンダリング処理を実装する必要が無くなりました。 でも、View までは提供してくれないのが Android スタイルw Android ではよくあること... 別件ですが MediaCodec とかも同じようなパターンですね。 MediaCodec は使い方がムズくて苦労しました... (今は ExoPlayer があるからだいぶ楽になりましたが) 欲しい PDF ビューアの機能 1ページづつ表示 スワイプでページ切り替え こんな感じの仕様の PDF ビューアであれば、ViewPager2 + PdfRenderer で割と簡単に作れたので、作成方法を紹介します。 実装 fragment_pdf_viewer.xml <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.viewpager2.widget.ViewPager2 android:id="@+id/view_pager" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:background="@color/black" /> </FrameLayout> view_holder_pdf.xml <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/black"> <ImageView android:id="@+id/image_view" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout> PdfViewerFragment.kt // package や import は諸事情により省略 class PdfViewerFragment : Fragment() { companion object { fun create(pdfFilePath: String): PdfViewerFragment { val result = PdfViewerFragment() result.arguments = Bundle() result.arguments?.putString("pdf_file_path", pdfFilePath) return result } } private lateinit var pager: ViewPager2 private var pdfRenderer: PdfRenderer? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { super.onCreateView(inflater, container, savedInstanceState) val view = inflater.inflate(R.layout.fragment_pdf_view, container, false) pager = view.findViewById(R.id.view_pager) // 横スワイプで切り替えたい場合は ViewPager2.ORIENTATION_HORIZONTAL にする pager.orientation = ViewPager2.ORIENTATION_VERTICAL // レンダラを作成(以下の処理は本当は非同期の方が良い) val file = File(requireArguments().getString("pdf_file_path")) val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) pdfRenderer = PdfRenderer(pfd) pager.adapter = Adapter() return view } inner class Adapter : RecyclerView.Adapter<ViewHolder>() { private val inflater = LayoutInflater.from(context) override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(position) override fun getItemCount() = pdfRenderer?.pageCount ?: 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(inflater.inflate(R.layout.view_holder_pdf, parent, false)) } inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val imageView = itemView.findViewById<ImageView>(R.id.image_view) private var bitmap: Bitmap? = null fun bind(position: Int) { val page = pdfRenderer?.openPage(position) ?: return if (page.width != bitmap?.width || page.height != bitmap?.height) { bitmap?.recycle() bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888) } val bitmap = this.bitmap ?: return page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) imageView.setImageBitmap(bitmap) page.close() } } } つまづきポイント page.close() が漏れると、2ページ目を openPage した時に java.lang.IllegalStateException: Current page not closed でクラッシュするので注意しましょう。 ピンチイン・アウトで拡縮したい場合 view_holder_pdf.xml の ImageView を PhotoView にすれば OK です。 view_holder_pdf.xml <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/black"> <com.github.chrisbanes.photoview.PhotoView android:id="@+id/image_view" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ViewPager2+PdfRendererで簡単なPDFビューアを作る

背景 とある Android アプリで PDF を閲覧する機能に AndroidPdfViewer (jcenter で配布されている) を使っていたのですが、2022年2月に jcenter が使えなくなるので、「その内、MavenCentral なりで publish してくれるだろう」 と呑気に構えていたのですが、README.md の先頭行に Looking for new maintainer! と書かれていたので、これはもう更新されないんだろうなと悟りました。 Android では iOS と違い OS 標準の WebView で PDF 表示とかしてくれません。 以下の記事にあるように、これで苦労したことがある Android プログラマは多いかもしれません。 ただし、Android 5.0 から API (PdfRenderer) が提供されているので、対応 OS が Android 5.0 以降であれば自前で PDF レンダリング処理を実装する必要が無くなりました。 でも、View までは提供してくれないのが Android スタイルw Android ではよくあること... 別件ですが MediaCodec とかも同じようなパターンですね。 MediaCodec は使い方がムズくて苦労しました... (今は ExoPlayer があるからだいぶ楽になりましたが) 欲しい PDF ビューアの機能 1ページづつ表示 スワイプでページ切り替え こんな感じの仕様の PDF ビューアであれば、ViewPager2 + PdfRenderer で割と簡単に作れたので、作成方法を紹介します。 実装 activity_main.xml <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <androidx.viewpager2.widget.ViewPager2 android:id="@+id/view_pager" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout> view_holder.xml <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/image_view" android:layout_width="match_parent" android:layout_height="match_parent" android:contentDescription="@string/pdf_page" /> </FrameLayout> MainActivity.kt package com.suzukiplan.pdfviewer import android.graphics.Bitmap import android.graphics.pdf.PdfRenderer import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 class MainActivity : AppCompatActivity() { private lateinit var viewPager: ViewPager2 private var pdfRenderer: PdfRenderer? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) viewPager = findViewById(R.id.view_pager) // 横スワイプで切り替えたい場合は ViewPager2.ORIENTATION_HORIZONTAL にする viewPager.orientation = ViewPager2.ORIENTATION_VERTICAL // レンダラを作成(以下の処理は本当は非同期の方が良い) pdfRenderer = PdfRenderer(assets.openFd("example.pdf").parcelFileDescriptor) viewPager.adapter = Adapter() } inner class Adapter : RecyclerView.Adapter<ViewHolder>() { override fun getItemCount() = pdfRenderer?.pageCount ?: 0 override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(position) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(layoutInflater.inflate(R.layout.view_holder, parent, false)) } inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val imageView = itemView.findViewById<ImageView>(R.id.image_view) private var bitmap: Bitmap? = null fun bind(position: Int) { val page = pdfRenderer?.openPage(position) ?: return if (page.width != bitmap?.width || page.height != bitmap?.height) { bitmap?.recycle() bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888) } val bitmap = this.bitmap if (null != bitmap) { page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) imageView.setImageBitmap(bitmap) } page.close() } } } 上記コードは GitHub で公開しています。 activity_main.xml view_holder.xml MainActivity.kt つまづきポイント page.close() が漏れると、2ページ目を openPage した時に java.lang.IllegalStateException: Current page not closed でクラッシュするので注意しましょう。 ピンチイン・アウトで拡縮したい場合 view_holder.xml の ImageView を PhotoView にすれば OK です。 view_holder.xml <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/black"> <com.github.chrisbanes.photoview.PhotoView android:id="@+id/image_view" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

LottieアニメーションをJetpack Composeで使用する

Jetpack Compose自体もUIのアニメーションはシンプルに実装できるフレームワークですが、Lottieを使うとより高度なアニメーションをデザイナー主導でリッチに表現することが手軽にできるようになります。 LottieはJetpack Composeにも対応しており、Jetpack Composeで組み立てているUIの中に簡単にLottieアニメーションを組み込むことができます。 まず、app/build.gradleにLottieの依存を追加します。 dependencies { implementation "com.airbnb.android:lottie-compose:4.2.1" } そして、src/main/res/raw/ディレクトリがなければ作成して、その中にLottieのJSONファイルを入れます。この記事ではこのファイルをmy_lottie_file.jsonだとして扱います。 あとは、Lottieアニメーションを配置したい場所にLottieAnimationComposableを入れるだけです。 @Composable fun MyUi() { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.my_lottie_file)) LottieAnimation( composition = composition, iterations = LottieConstants.IterateForever, ) } 公式のリリースノートにいくつかの使い方の例が載っています。 上で紹介したコードはアニメーションを自動で開始して無限に繰り返しますが、一度だけアニメーションを再生したい場合はiterationsを省略(デフォルト値の1を使用)すれば良いですし、もっと細かくアニメーションを制御したかったらanimateLottieCompositionAsStateを使うと良いみたいです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

事業フェーズごとにiOS/Androidアプリ開発で気をつけるべき観点

色々な事業フェーズに携わる中で、事業フェーズ別のアプリ開発に関する観点に対して思う所があったので個人的な見解をまとめます。 前段 事業状況は0→1、1→10、10→100などの局面によって変化していくものです。 その事業の局面ごとに最適なアプリの設計や重視すべき観点というのは変化していくのではないかと私は思います。 もちろん、アプリの特性と組織状況によっても最適なアプリの設計は変わるものですが、そこはサブ要因として今回は踏み込まずに思うところを記載していこうと思います。 0→1フェーズ 0→1フェーズは仮説検証を繰り返し行い、ビジネスが成立するコアサイクルを見つけ出すフェーズです。 この局面ではアプリに必要とされる機能やUXもどんどん変化するため、そもそもネイティブアプリとして作るべきかどうかも含めて検討すべきフェーズです。 可能であれば、NoCodeによるアプリ開発で簡素な仮説検証を繰り返して一定の方向性を見つけてからアプリ開発を開始すべきでしょう。 この段階で実際にアプリを開発する場合、仕様変更が頻発するとともに0→1に興味を持つ少数のエンジニアでどんどん開発を進めていく形になると思われます。 この局面では、アプリ開発において以下の観点を重視すべきでしょう。 安定性よりも作りやすさを優先(スクラップ&ビルド) 学習コストが小さく、参入障壁が低い設計 テストよりも動くものを優先 仮説検証の状況の可視化(定性・定量調査とその可視化) 1→10フェーズ 1→10フェーズはビジネスのコアバリューが形成され、売り上げを拡大していくフェーズです。 この局面ではコアの機能しかないアプリに色々な機能追加やUX改善が求められます。 また、0→1にこだわるエンジニアが抜けたりすることで人の入れ替えが激しくなってくる時期でもあります。 この段階におけるアプリ開発においては以下の観点を重視すべきでしょう。 コア機能に対するテストの実装 開発ドキュメントの整備(暗黙知から形式知への変換) リファクタリング(あるいはリニューアル)の検討と実施 10→100フェーズ 10→100フェーズは事業が安定した状態に入り、運用に重きを置くフェーズです。 この局面ではユーザ数が一定存在することもあり、細かいUI/UXの改善や不具合修正、外部(他アプリや他企業のAPIなど)との連携といった開発がメインとなってきます。 この時期には人員が十分な数配置され、技術主導の開発が進んでいる組織も存在するでしょう。 この段階におけるアプリ開発においては以下の観点を重視すべきだと思われます。 組織体制に応じた設計ルールの制定(人員が十分なら細かく責務を分けて厳密な設計ルールにするなど組織状況に応じた判断が必要) テストの拡充 運用周りの自動化推進 雑感 具体的な設計という意味では、初期フェーズではiOSならMVC、AndroidならMVVMで構成するにとどめておき、Clean Archなどを適用するとしても厳密なルールを適用しない方が小回りが利くと思います。 1→10フェーズではアプリの特性に合わせて設計を考え、場合によってはリニューアルする感じになるでしょう。 1→10でリファクタリングもしてないケースでは、10→100フェーズになってから大掛かりなリニューアルなどを行う羽目になり苦労している現場もあると思います。 逆に、0→1フェーズで作り込みをやりすぎて、1→10になってから作った人が抜けて誰も触れない設計になっているというのも聞いたことがあるので、事業フェーズと人員体制によってアプリに適した設計というのは変化していくことを意識してほしいと思う所です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Timber×Crashlytics でログをクラッシュレポート送信(Timber編)

はじめに リリースしたモバイルアプリで、未検出のバグによりクラッシュしてしまう場合、ユーザーが声を上げない限り検知できない・・ということは避けたいですよね。 また、万が一そのような不具合があった場合、再現手順やユーザーの状態を調査・分析するのは骨が折れます。 更には、リリースしたモバイルアプリの動作ログを自前のプラットフォームに集約するような仕組みも敷居が高いです。 それらを踏まえて、 クラッシュするまでの詳細なログをクラッシュレポートで確認したい ログはいい感じに出力したい(if (Logger.isDebugEnabled()) { ... }みたいな分岐はイヤ) 手軽に導入したい という思いのもと、Timber と Firebase Crashlytics での実装例を紹介したいと思います。 Timber Timber は、Android向けのログ出力ライブラリです。 ログと言えば、Androidの標準ロガーとして、android.util.Logを使用することが多いと思います。 Androidの標準ロガー しかし、下記の記事で触れられていますが、標準のロガーを使用していると以下の問題が出てきます。 クラスごとにログ出力時のタグ用の変数を用意する必要がある private final String TAG = MainActivity.class.getName(); と冒頭に宣言 リリースビルドしたアプリの logcat にデバッグログが出力されてしまう ProGuard 等で削除していれば問題はないが、機密情報を出力していたとしたら大変・・・ デバッグビルドなのに Crashlytics へのクラッシュレポートにログが出力されてしまう ログごとに “リリースビルドなら“ みたいな分岐はしたくない・・・ Timber を使うメリット 各問題に対して、Timber を使用することでのメリットを挙げます。 ・クラスごとにログ出力時のタグ用の変数を用意する必要がある 呼び出し元クラス名が自動でタグに設定されるため、変数の用意や引数への指定が不要になります。 // 導入前 private final String TAG = MainActivity.class.getName(); ... Log.d(TAG, String.format("昨日のご飯は%s鍋!", "トマト")); // 出力例 // D/com.example.timbercrashlyticstest.MainActivity: 昨日のご飯はトマト鍋! // 導入後 Timber.d("昨日のご飯は%s鍋!", "トマト"); // 出力例 // D/MainActivity: 昨日のご飯はトマト鍋! ・リリースビルドしたアプリの logcat にデバッグログが出力されてしまう ・デバッグビルドなのに Crashlytics へのクラッシュレポートに出力されてしまう リリースビルド/デバッグビルドによって、ログ出力を切り替えできるため、容易に制御が可能になります。 ※ 後述します 導入方法 build.gradle(:app) TimberのGitHub を参考にモジュールを追加します。 builde.grade(app) dependencies { + implementation 'com.jakewharton.timber:timber:4.7.1' } ※ 執筆時点(2021/11/18)での最新は 5.0.1 ですが、筆者の環境の都合上バージョンを落としています MyApplication Tree クラスを plant することで、アプリケーション全体で Timber クラスを使用できます。 MyApplication.java import android.app.Application; + import timber.log.Timber; public class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); + Timber.plant(new Timber.DebugTree()); } } AndroidManifest.xmla manifest/application/android:name に、アプリケーションクラスを指定 AndroidManifest.xml <application ... + android:name=".MyApplication" ...> MainActivity MainActivity.java public class MainActivity extends AppCompatActivity { private final String TAG = MainActivity.class.getName() + "_TAG"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Log.v(TAG, "Log VERBOSE"); Log.d(TAG, "Log DEBUG"); Log.i(TAG, "Log INFO"); Log.w(TAG, "Log WARN"); Log.e(TAG, "Log ERROR"); Log.println(Log.ASSERT, TAG, "Log ASSERT"); Timber.v("Timber VERBOSE"); Timber.d("Timber DEBUG"); Timber.i("Timber INFO"); Timber.w("Timber WARN"); Timber.e("Timber ERROR"); Timber.wtf("Timber ASSERT"); } } 出力例 Timber の方はパッケージ名が出力されないのですっきりしています。 パッケージ名が必要であれば、お好みで TAG 変数をタグとして設定してください。 ※ Crashlytics を使用する場合、パッケージ名が冗長となり得るなら除く方針をお勧めします Logcat Runウィンドウ ビルドによる切り替え 下記のように、ビルドによって plant するクラスを切り替えることで、logcat に出力するログを優先度ごとに制御することが可能になります。 つまり、MainActivity では、ビルドモードを意識して分岐するといった処理が必要なくなります。 ※ Tree.DebugTree は全優先度のログを出力します MyApplication BuildConfig.DEBUG でビルド別に分岐します。 デバッグビルドの場合は DebugTree、リリースビルドの場合は ReleaseTree を plant します。 MyApplication.java public class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); + if (BuildConfig.DEBUG) { Timber.plant(new Timber.DebugTree()); + } else { + Timber.plant(new ReleaseTree()); + } } } ReleaseTree Timber.Tree を継承したリリースビルド用の Tree クラスを作成します。 下記は実装の一例です。 ReleaseTree.java public class ReleaseTree extends Timber.Tree { @Override protected void log(int priority, @Nullable String tag, @NotNull String message, @Nullable Throwable t) { switch (priority) { case Log.INFO: case Log.WARN: case Log.ERROR: case Log.ASSERT: // Crashlytics ログ出力処理を記述(Crashlytics編で記載) break; default: /* 下記の優先度は出力なし ・Log.VERBOSE ・Log.DEBUG */ break; } } } おまけ Lint Timber を追加すると Lint も設定されるため、既に Log クラスで実装してしまった場合でも、Log クラスで実装しようとしたときも、気付けるようになっていて便利です。 関数名の出力 Timber.Tree ではなく、Timber.DebugTree を継承し、createStackElementTag をオーバーライドすることで、呼び出し元の関数名を出力するようにカスタマイズが可能です。 OmakeDebugTree.java public class OmakeDebugTree extends Timber.DebugTree { @Override protected @Nullable String createStackElementTag(@NotNull StackTraceElement element) { // ClassName#MethodName() return String.format( "%s#%s", super.createStackElementTag(element), element.getMethodName() ); } } ※ OmakeDebugTree を plant してください 出力例(Runウィンドウ) #のあとに、関数名が出力されました。 おわりに これで、デバッグビルドの際はコンソール上にログを出力し、リリースビルドの際はクラッシュレポートに必要なログのみを出力する基盤ができました。 クラッシュレポートを送信するサービスは様々なものがありますので、応用する際に参考になれば幸いです! 次回は Firebase Crashlytics を ReleaseTree に組み込んでいきたいと思います。 参考 Androidのログ出力ライブラリ”Timber” ProGuardでデバッグログを"完全"に削除することの難しさ
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む