- 投稿日:2019-12-02T23:45:40+09:00
android開発で実機デバイスからAPIを叩けないときのトラブルシューティング
こんにちは
最近ノリでdjangoAPIを作り始めた雰囲気エンジニアです。
ラズパイでサーバー構築したのですが、djangorestframeworkをうまくインストールできず、まずはローカルで環境構築してdockerでデプロイしたいななんて思ってます
…情弱の僕には厳し(ry
とまあそんなこんなで、ローカルにてAPIを作り始めたのですが、お決まりの
python3 manage.py runserver
で、サーバーを動かすじゃないですか
WEBからならAPIを叩けるのに実機ANDROIDからは叩けません①python3 manage.py runserver 0.0.0.0:80でサーバーを起動すること
理由はよくわかりません
android側では192.168.x.xなどのパソコンのプライベートIPを叩きます②Error: That port is already in use.
ポートがすでに使われていますというエラーが出たら(①のあとによく出てきます)sudo lsof -i:80
これで起動中の80番ポートを使っている機器が出てきます。
そしたら
sudo kill -9 <PID>(4, 5ケタの数字)
これで全部ころしてください
③java.net.ConnectException: failed to connect to /192.168.x.x (port 80): connect failed: ENETUNREACH (Network is unreachable)
それでもまだしつこく出てくるエラーがあります
とくにうちはwifiの調子が悪いのでこの場合は
・実機デバイスがWIFIにつながっているか(ネットに繋ぐなりしてみてください)
・パソコンがWIFIにつながっているか
・パソコンのプライベートIPが変更されてしまっていないか備忘録的に活用したいと思います
- 投稿日:2019-12-02T23:01:00+09:00
ViewPager2 + TabLayout + DataBinding
はじめに
DMMグループ Advent Calendar 20193日目を担当します!@mii-chang です!
2018年新卒入社でDMMにJOINし、今は AQUIZ というクイズアプリのAndroidアプリを開発しています
https://aquiz.jp/クイズに答えてお金がもらえる!面白いアプリなのでぜひ皆さん遊んでくださいね〜〜!?
さて、今回はJetpackに新しく追加された
ViewPager2
について書いていきたいと思います!ViewPager2 とは
従来の
ViewPager
の進化版のViewPager2
。
今までは、各FragmentをPagerAdapter
を使って表示切り替えをしていました。
ViewPager2
では、PagerAdapter
の代わりにRecyclerView.Adapter
を使います。
リファレンス何がいいの?
今までは、
PagerAdapter
に切り替えたいFragmentをそれぞれセットしていましたが、
新しいViewPager2
では、画面切り替えをRecyclerViewベースでやってくれるので、Viewのレイアウトを作っておけばそれを再利用してくれます。
要するに、Fragmentが1つで良くなったのです
また、RecyclerViewベースなので、RecyclerViewの知識があれば扱いやすいのも魅力ですね使ってみよう
今回は、Databindingを一緒に使って、簡単なサンプルを作ってみます。
導入
app配下の
build.gradle
に以下を追加しますbuild.gradleimplementation "androidx.viewpager2:viewpager2:1.0.0-beta04"バージョン情報は公式リファレンスを見てください!
実装
サンプルとして、クリスマスアイテムの画像と名前のリストを、ViewPagar2を使って表示してみます
レイアウト
今回は、ViewPagerと、TabLayoutを使って、インジケーター付きのレイアウトを作ります
fragment_main.xml<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" tools:context=".MainFragment"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.viewpager2.widget.ViewPager2 android:id="@+id/viewpager" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <com.google.android.material.tabs.TabLayout android:id="@+id/tab_layout" android:layout_width="0dp" android:layout_height="wrap_content" app:tabGravity="center" app:tabIndicatorColor="@color/colorAccent" app:tabIndicatorGravity="center" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"/> </androidx.constraintlayout.widget.ConstraintLayout> </layout>TabLayout
tabGravity
tabGravity
は、インジケーター全体の横幅を決められます。
center fill 真ん中寄せになる 横幅いっぱいにインジケーターが広がる tabIndicatorColor
tabIndicatorColor
は、その名の通り、インジケーターの色を設定できます。tabIndicatorGravity
tabIndicatorGravity
は、TabLayout内でのインジケーターの位置を指定できます。(View全体の背景が緑、インジケーターの色がピンク)
top stretch
center bottom
ViewPater2にセットするアイテムのレイアウトを作ります。
今回はサンプルとして、文字と画像をDataBindingを使って表示させてみます。view_christmas.xml<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="christmas" type="com.miichang.viewpagersample.Christmas" /> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{christmas.title}" android:textColor="#000" android:textSize="50sp" app:layout_constraintBottom_toTopOf="@id/imageView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="ケーキ" /> <ImageView android:id="@+id/imageView" android:layout_width="200dp" android:layout_height="200dp" android:layout_marginTop="20dp" android:scaleType="fitCenter" android:src="@{christmas.drawable}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/textView" tools:src="@drawable/cake" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>BindingAdapter
今回は、DrawableをDataBindingで受け渡す想定で、DrawableをImageViewにセットするためのBindingAdapterを作りました。
ChristmasBindingAdapter.ktinternal object ChristmasBindingAdapter { @JvmStatic @BindingAdapter("android:src") fun setDrawable( imageView: ImageView?, drawable: Drawable? ) { if (imageView == null) return drawable?.let { imageView.setImageDrawable(drawable) } } }バインドするデータクラス
今回は、
Christmas
データクラスを作って、この内容をバインドさせますChristmas.ktdata class Christmas( val title: String, val drawable: Drawable? )ViewHolder
レイアウトに定義したデータタグに、Christmasデータクラスの内容を渡すために、
RecyclerView.ViewHolder
を継承したViewHolderクラスを作ります。ItemViewHolder.ktinternal class ItemViewHolder( itemView: View, private val binding: ViewChristmasBinding ) : RecyclerView.ViewHolder(itemView) { companion object { fun create( inflater: LayoutInflater, container: ViewGroup, attachToRoot: Boolean ): ItemViewHolder { val binding = ViewChristmasBinding.inflate(inflater, container, attachToRoot) return ItemViewHolder(binding.root, binding) } } fun bind(item: Christmas) { binding.apply { christmas = item executePendingBindings() } } }Adapter
表示させたいデータクラスのリストをViewHolderに受け渡すために、
RecyclerView.Adapter
を継承したアダプタークラスを作ります。ViewPagerAdapter.ktinternal class ViewPagerAdapter : RecyclerView.Adapter<ItemViewHolder>() { private var list: List<Christmas> = listOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { val inflater = LayoutInflater.from(parent.context) return ItemViewHolder.create(inflater, parent, false) } override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { holder.bind(list[position]) } fun setItem(list: List<Christmas>) { this.list = list notifyDataSetChanged() } override fun getItemCount(): Int = list.size }
onBindViewHolder
で、リストの1つ1つを受け渡します。Fragment
Frafmentでアダプターを初期化します
今回はサンプルなので、Fragmentでadapterに直接文字とDrawableリソースを入れたリストを突っ込みました。MainFragment.ktclass MainFragment : Fragment() { companion object { fun newInstance(): MainFragment = MainFragment() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val christmasItems = listOf( Christmas( title = "サンタ", drawable = ContextCompat.getDrawable(requireContext(), R.drawable.santa) ), Christmas( title = "ツリー", drawable = ContextCompat.getDrawable(requireContext(), R.drawable.tree) ), Christmas( title = "トナカイ", drawable = ContextCompat.getDrawable(requireContext(), R.drawable.reindeer) ), Christmas( title = "プレゼント", drawable = ContextCompat.getDrawable(requireContext(), R.drawable.presents) ), Christmas( title = "ケーキ", drawable = ContextCompat.getDrawable(requireContext(), R.drawable.cake) ), Christmas( title = "チキン", drawable = ContextCompat.getDrawable(requireContext(), R.drawable.chicken) ) ) val binding = FragmentMainBinding.inflate( inflater, container, false ) val adapter = ViewPagerAdapter() binding.apply { binding.viewpager.adapter = adapter adapter.setItem(christmasItems) TabLayoutMediator( tabLayout, viewpager, TabLayoutMediator.TabConfigurationStrategy { tab, position -> } ).attach() } return binding.root } }TabLayoutMediator
TabLayoutMediator
にTabLaoutとViewPager2を渡してあげることで、ViewPagerのスクロールとTabLayoutのインジケーターがリンクしますTabLayoutMediator( tabLayout, viewpager, TabLayoutMediator.TabConfigurationStrategy { tab, position -> } ).attach()これでViewPager2が使えます!RecyclerViewなので結構スムーズですね
ViewPager2でできること
縦スクロール
ViewPager2はRecyclerViewなので、なんと、ViewPager2自体にorientationを設定するだけで超かんたんに縦方向のスクロールに変更することもできます!
android:orientation="vertical"画面切り替えアニメーション
ViewPager2に対して、
setPageTransformer
を呼ぶと、画面切り替え時のアニメーションを設定できます。
今回はリファレンスに載っている
ZoomOutPageTransformer
アニメーションをそのまま実装してみました。binding.viewpager.setPageTransformer(ZoomOutPageTransformer())スワイプ時のアニメーションがこんなにリッチになるとかなりテンションが上りますね?
リスナー
ViewPager2に対して
registerOnPageChangeCallback
を呼ぶと、リスナーが拾えますbinding.viewpager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageScrollStateChanged(state: Int) { super.onPageScrollStateChanged(state) } override fun onPageScrolled( position: Int, positionOffset: Float, positionOffsetPixels: Int ) { super.onPageScrolled(position, positionOffset, positionOffsetPixels) } override fun onPageSelected(position: Int) { super.onPageSelected(position) } })onPageScrollStateChanged
onPageScrollStateChanged
は、スクロール状態の変更が取得できます。
SCROLL_STATE_IDLE SCROLL_STATE_DRAGGING SCROLL_STATE_SETTLING 何もしていないとき ドラッグ開始時 指が離れたとき onPageScrolled
onPageScrolled
は、スクロールされているときに、画面がどれくらい動いているかを取得できます。
position positionOffset positionOffsetPixels ページのインデックス番号 スクロールで動いた距離 スクロールで動いた距離のピクセル数 onPageSelected
onPageSelected
は、今表示されている画面のインデックス番号を取得できます。おわりに
今回は
ViewPager2
について調べる機会があったので、まとめてみました!
まだQiita上にも情報が少なかったので、どなたかの役に立てれば幸いですサンプルのリポジトリをGitHubに置いておいたので全体が見たい場合はこちらをどうぞ!
https://github.com/mii-chang/ViewPager2Sample
DMMグループ Advent Calendar 2019明日の担当は、 @karayok さんです!
参考
https://developer.android.com/jetpack/androidx/releases/viewpager2
https://medium.com/google-developer-experts/exploring-the-view-pager-2-86dbce06ff71
https://proandroiddev.com/look-deep-into-viewpager2-13eb8e06e419
https://developer.android.com/reference/com/google/android/material/tabs/TabLayoutMediator
- 投稿日:2019-12-02T19:42:30+09:00
スマホアプリのコマンドビルドまとめ(Xamarin編)
前回の『スマホアプリのコマンドビルドまとめ(iOS編)』に引き続き、今回はXamarinのCI環境を作る際に溜まった知見をまとめた記事です。 前提として、Mac mini/Mac Pro(ゴミ箱)な…
- 投稿日:2019-12-02T16:35:23+09:00
初めてのConstraintLayoutでChainStyleを利用してみた
はじめに
新卒で初めてAndroidアプリの実装を行いました。
その際、ConstraintLayoutの利用方法について調べてみたので、
アウトプットとして同じ初心者の方の参考になれば良いと思い記事にしました。目次
- ConstraintLayoutとは
- Chainの設定方法
- ChainStyleの種類
- Weightの制約
ConstraintLayoutとは
ConstraintLayoutは、ウィジェットを柔軟な方法で配置およびサイズ設定できるようにするものです。以下のように様々な制約をつけることができます。
- 相対位置決め
- マージン
- センタリング位置決め
- 円形位置決め
- 可視性の動作
- 寸法拘束
- チェーン
- 仮想ヘルパーオブジェクト
- オプティマイザ
Chainの設定方法
ChainStyleの種類
Chainは、双方向の位置制約で相互にリンクされているViewのグループです。
Chainは起点となる位置のViewに、layout_constraintHorizontal_chainStyle もしくは、layout_constraintVertical_chainStyleを利用することで設定できます。Spread
Viewがマージンをとって均等に配置されます。
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintHorizontal_chainStyle="spread" />Spread inside
両端のマージンは取らずに、中のマージンをとり均等に配置されます。
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintHorizontal_chainStyle="spread_inside" />Packed
一つの要素としてまとめられ、マージンをとります。
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintHorizontal_chainStyle="packed" />Weightの制約
ChainStyleがspreadまたはinside insideに設定されている場合、ウエイトを用いてViewの幅を指定することが出来ます。このとき、HorizontalかVerticalに応じて、Viewのwidthまたはheightを「match constraint(0dp)」にする必要があります。
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/mainLayout" android:layout_width="match_parent" android:layout_height="match_parent" tools:layout_editor_absoluteX="0dp" tools:layout_editor_absoluteY="0dp"> <TextView android:id="@+id/textView1" android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/view" android:textSize="25sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/textView2" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintHorizontal_chainStyle="spread" app:layout_constraintHorizontal_weight="1" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/textView2" android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/view" android:textSize="25sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/textView3" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintHorizontal_weight="2" app:layout_constraintStart_toEndOf="@+id/textView1" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/textView3" android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/view" android:textSize="25sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintHorizontal_weight="3" app:layout_constraintStart_toEndOf="@+id/textView2" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>参考
https://developer.android.com/reference/android/support/constraint/ConstraintLayout.html
https://medium.com/@nomanr/constraintlayout-chains-4f3b58ea15bb
- 投稿日:2019-12-02T14:56:44+09:00
Firebase App Distributionの特徴とCLIの使い方
この記事は、Firebase #2 Advent Calendar 2019の3日目の記事です。
今年の9月下旬ごろにリリースされた新機能、App Distributionについて解説します。
App Distributionとはなにか
Firebaseが提供する、テスター向けのアプリ配信ツールです。同様のツールとしてDeployGateやFabric betaが有名です。ちなみにFabricは、Googleが2017年にTwitterから買収しています。
はっきりと明言していませんが、Fabricは2020年3月末にサービスが終了することもあり、App DistributionはFabric betaの代替として用意されたものとして考えればいいと思います。実際に使い方も機能も、ほぼほぼ違いがありません。
なお、この記事を執筆時点ではまだベータ版となっています。
App Distributionの使い方(配信側)
GUIでの操作はFirebaseのコンソールサイト上から行います。配信したいアプリのapkをドラッグし、配信したいユーザーのメールアドレスを設定するだけです。予めテスターのグループを作成することで一括配信もできますし、招待リンクを作成して不特定のユーザーに配信することも可能です。
招待リンクでは、メールアドレスのドメインに制限をかけることができます。
Fabric betaにもあった、ユーザーごとの招待からの進捗も確認可能です。ただし、インストールしたアプリを起動したかどうかのチェックまでは実装されていません。
App Distributionの使い方(テスター側)
アプリが配信されると、テスターのメールアドレスに上記のメールが送信されます。「Download the latest build」ボタンをタップすることで、インストールするためのWebサイトへ遷移します。
初めてアクセスすると、利用するGoogleアカウントを聞かれるので、メールが届いたアカウントで登録を行ってください。
以降はFabric betaとほぼほぼ同様です。インストールしたいバージョンを選び、Downloadボタンをタップしてダウンロードとインストールを行います。ちなみに遷移直後に「App Tester」というアプリのインストールを進められますが、これをいれなければインストールできないというわけではありません。Fabric betaではアプリが必須であったことと、現在どのアカウントでログイン中か分かりづらいという問題点がありましたが、この辺りはApp Distributionの方が便利だと感じました。
CLIからApp Distributionにapkをアップロードする
App DistributionではGradleを使ってapkをアップロードすることも可能です。これによって、CLI上から簡単にapkをアップロードできますし、Firebaseプロジェクトのデプロイ周りの設定が整っていれば、CIを使ってアップロードを自動化することも可能です。
App Distributionのgradle対応を追加する
(注意)すでにFirebaseプロジェクトが組み込まれていることを前提に記述しています。
rootプロジェクトのbuild.gradleに必要なライブラリを追加します。
build.gradlebuildscript { repositories { google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:3.5.2' classpath 'com.google.gms:google-services:4.3.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // これを追加 classpath 'com.google.firebase:firebase-appdistribution-gradle:1.2.0' } }これだけで対応完了です。
./gradlew appDistributionUpload[BUILD-VARIANT]
で指定したBUILD-VARIANTのapkをビルドしてApp Distributionにアップロードしてくれます。公式のドキュメントでは
assemble[BUILD-VARIANT]
もセットでやるように書かれていますが、試しに無しでやってみたところ、appDistributionUpload[BUILD-VARIANT]
でビルドもやってくれたので不要な気がします。
が、よほどビルドに時間のかかるアプリでなければ、ドキュメント通りに両方とも記載するのをおすすめします。
./gradlew assemble[BUILD-VARIANT] appDistributionUpload[BUILD-VARIANT]
アップロード時にテスターやリリースノートを自動で設定する
apkを自動でアップロードしても、テスターを都度コンソールから設定したのではあまり嬉しさがありません。ですので、デフォルトのテスターを設定するようにしてみます。合わせてリリースノートにも必要な情報を予め記述するようにします。
app/build.gradleapply plugin: 'com.google.firebase.appdistribution' // リリースノートにビルド時のブランチ情報などを記載するために取得 def gitSha = 'git rev-parse --short HEAD'.execute([], project.rootDir).text.trim() def gitBranch = (System.getenv("CIRCLE_BRANCH") != null) ? System.getenv("CIRCLE_BRANCH") : 'git rev-parse --abbrev-ref HEAD'.execute([], project.rootDir).text.trim() def buildTime = new Date().format("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("JST")) // ... android { buildTypes { release { // ... firebaseAppDistribution { releaseNotes = "${gitBranch}(${gitSha}) - ${buildTime}" groups = "group-xxx" } } stage { // ... firebaseAppDistribution { releaseNotes = "stage / ${gitBranch}(${gitSha}) - ${buildTime}" groups = "group-xxx" } } }これでGradleからアップロードするたびに、ブランチ情報がリリースノートに予め記述された状態で、テスターグループの「group-xxx」に自動的に送信されるようになりました。
まとめ
最初にも記載しましたが、Fabric betaと大きな違いは少ないため、こちらを利用していた方は比較的簡単に移行できる印象を持ちました。
ただしまだベータ版ではあるため、正式版の時点で機能がいくつか変わる可能性もります。十分留意するようにしてください。ありがちな疑問など
サイトやApp Testerは日本語化されてるの?
されていません。ですがUI自体はとても単純なので、ある程度フォローすれば問題はなさそうな気がします。
よほど英語にアレルギーがある相手がテスターである場合はDeploygateを採用するのも有りだと思います。.aabではアップロードできないの?
できません。apkとaabによる挙動の違いが心配だというのであれば、Google Playのテスター機能を使うべきだと思います。
アップロードしてもApp TesterがPush通知してくれない?
現時点ではApp TesterはPush通知をサポートしていないようです。ユーザーへの配信通知は、Fabric beta同様にメールによって伝えられます。
ひとつのFirebaseプロジェクトで複数のアプリをApp Distributionに登録できるの?
そのFirebaseプロジェクトに登録しているアプリであれば可能です。プラットフォームやapplicationIdごとに分けられていて、App Distribution横のコンボボックスから切り替えることになります。
参考
- 投稿日:2019-12-02T10:42:40+09:00
Glideでキャッシングぅううう
はじめに
[CA Tech Dojo/Challenge/JOB Advent Calendar 2019]の2日目はbatchが書かせていただきます.
私は今年の夏にCA Tech Dojo(Kotlin編)に参加させていただきました.めちゃくそ最高のインターンだったので来年も開催される場合は,おすすめです.Dojoはある技術の初心者の導入部分をサポートしてくれるインターンなので,最近気になってる技術をメンターさんに教えてもらいながら学ぶことができます.
詳しくはコチラ本題
Glideを使って画像をリスト内で表示して,リストのItemがクリックして別の画面に遷移して,遷移先でリストに表示していた画像を使いまわしたいみたいなことはよくあると思います.そこで,Navigationなどつかって遷移先にも画像のURLをStringで渡して表示するということをしても実装できますが,GlideではURLをキーとして画像をキャッシュしてまた,遷移先でおなじキーでキャッシュからデータを取ってこれるやり方があるそう.
なんかわざわざそれしなくてもかってにキャッシュしてくれてそうな感じもしますが,とりあえず明示的にキャッシュしてそれを遷移先で取得するやり方の知見共有したいと思います.Step1 キャッシュ
以前,Groupieでitemクリックを実装する記事を書きました.
今回はitemがクリックされたときに動かすとはじめにで言ったような動きを実装することができます.
Glide.with
のとこでcoverPathをキーとして画像をキャッシュしています.
そして,遷移先で同じキーでキャッシュから取り出したいので,actionに渡してNavigationで遷移しています.val index = this.playlistAdapter.getAdapterPosition(item) val coverImage = requireActivity().findViewById<ImageView>(R.id.cover_image_view) val coverPath = viewModel.playlists.value?.get(index).coverPath ?: return@OnItemClickListener Glide.with(this) .load(coverPath) .diskCacheStrategy(DiskCacheStrategy.ALL) .into(coverImage) val action = PlaylistFragmentDirections.actionMusicFragment() action.coverPath = coverPath Navigation.findNavController(requireActivity(), R.id.nav_host_fragment).navigate(action)Step2 キャッシュから取り出す
遷移先の画面では,わたされてきたcovetPathをsafeargsで受け取ってそれをキーとしてキャッシュから画像を表示させています.
val coverImageView = view.findViewById<ImageView>(R.id.cover_image_view) val args: MusicFragmentArgs by navArgs() val coverPath = args.coverPath Glide.with(this) .load(coverPath) .onlyRetrieveFromCache(true) .into(coverImageView)参考
https://bumptech.github.io/glide/doc/caching.html#cache-keys
- 投稿日:2019-12-02T09:39:00+09:00
Android リアルタイム入力でハッシュタグ形式に文字装飾するTIPS
本稿の目的
#
から始まるハッシュタグの部分の色が変わり、タップするとハッシュタグの内容に応じたフィードの検索などを行う機能は最近ではあたりまえのUXになっています。Androidでの文字装飾は
UnderlineSpan
や、ForegroundColorSpan
を組み合わせれば簡単に実装できますが、文字装飾した部分をクリックできるようにしたり、TwitterやFacebookのようにリアルタイムで入力した内容に応じて文字装飾を行うためにはどのように実装するべきかを説明します。ラベルにハッシュタグの形式に相当する部分を文字装飾する&クリックできるようにする
ハッシュタグの形式に相当する部分を正規表現をして抽出する
文字装飾するには
Spannable
を実装したCharSequence
をTextView
に設定します。任意の文字列、もしくはすでに
TextView
に設定されている文字列からハッシュタグの形式に相当する部分を正規表現をして抽出し、文字装飾を行います。val spannableStringBuilder = SpannableStringBuilder(charSequence) val matcher = "(?:^|\\s)(#([^\\s]+))[^\\s]?".toRegex().toPattern().matcher(charSequence) while (matcher.find()) { if (matcher.groupCount() != 2) continue // index[1]...ハッシュタグの`#`を含む文字列 // index[2]...ハッシュタグ内のコンテンツの文字列 val content = charSequence.subSequence(matcher.start(2), matcher.end(2)) val st = matcher.start(1) val ed = matcher.end(1) Log.d("HASH_TAG", "detected hash tag. start:=$st, end:=$ed, tag:=$content") spannableStringBuilder.setSpan(UnderlineSpan(), st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) ForegroundColorSpan(Color.BLUE).run { spannableStringBuilder.setSpan(this, st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } }上記例では
CharSequence
を実装したインスタンスcharSequence
をもとにSpannableStringBuilder
を生成し、「空白、または行頭で#
からはじまる1文字以上の空白までの最短一致の文字列」をハッシュタグとして検出、下線と文字色を青に装飾しています。なお、Twitterなどでは
#
以降は記号や数字を許容していませんが、上記の例では空白以外のすべてを対象としています。
Twitterのように記号と数字を除き、「ひらがな、カナ、英字、漢字」のみを対象とする例だと正規表現は(?:^|\\s)(#([ぁ-んァ-ンーa-zA-Z一-龠\\-\\r]+))[^\\s]?
とおきかえてください。大事な注意点について
Spannable.setSpan
は重複して設定される前述のコード例で
CharSequence
を実装したインスタンスcharSequence
がSpannable
も実装しており、すでに何らかの文字装飾が行われている場合はその文字装飾も引き継がれます。
このため、前述のコード例でのcharSequenceが
TextView
から取得したもので、すでに設定されている文字装飾などを除去したい場合は、SpannableStringBuilder
のコンストラクタ引数はCharSequence.toString
を指定します。val spannableStringBuilder = SpannableStringBuilder(charSequence.toString())さらには、前述のコード例で設定している
Spannable.setSpan
も同じ処理を複数回呼び出せば、見た目上は同じでも都度同じSpan増えていきます。この点の回避策などは後述します。
(?:^|\\s)(#([ぁ-んァ-ンーa-zA-Z一-龠\\-\\r]+))[^\\s]?
では世界を制せない「記号と数字はハッシュタグの対象にしたくない」ということで、
(?:^|\\s)(#([ぁ-んァ-ンーa-zA-Z一-龠\\-\\r]+))[^\\s]?
の正規表現への置き換え例をあげましたが、この正規表現だと、アラビア文字(العالم لي)などはハッシュタグとして認識されなくなります。これでは世界を制せませんね。val spannableStringBuilder = SpannableStringBuilder(charSequence) val matcher = "(?:^|\\s)(#([^\\s]+))[^\\s]?".toRegex().toPattern().matcher(charSequence) while (matcher.find()) { if (matcher.groupCount() != 2) continue // index[1]...ハッシュタグの`#`を含む文字列 // index[2]...ハッシュタグ内のコンテンツの文字列 val content = charSequence.subSequence(matcher.start(2), matcher.end(2)) val st = matcher.start(1) val ed = matcher.end(1) Log.d("HASH_TAG", "detected hash tag. start:=$st, end:=$ed, tag:=$content") // サロゲートペアが含まれる場合は対象外とする if (content.length != content.toString().codePointCount(0, content.length)) continue spannableStringBuilder.setSpan(UnderlineSpan(), st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) ForegroundColorSpan(Color.BLUE).run { spannableStringBuilder.setSpan(this, st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } }ただ、?????といった文字はハッシュタグにしたくないよ、という場合は、前述のコード例でサロゲートペアは除外する処理を追加してください。
補足すると、?????らのEmojiはサロゲートペアで表現されており、こちらでサロゲートペアが含まれている場合は除去する仕組みを取っています。
こちらは 絵文字を支える技術の紹介 の記事を大変参考にさせていただきました。ハッシュタグの形式に相当する部分をクリックできるようにする
前述の正規表現で抽出した範囲に対して
ClickableSpan
を設定し、抽象メソッドを実装してください。spannableStringBuilder.setSpan(object : ClickableSpan(){ override fun onClick(widget: View) { Log.d("HASH_TAG", "hash tag clicked. tag:=$content") // 処理を記述する } }, st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)EditTextで入力中にハッシュタグ形式に変換する
EditText.addTextChangedListener
で入力の監視を行い、TextWatcher.afterTextChanged
で前述の正規表現を使ってハッシュタグに相当する文字装飾します。ただし、このときに次のような点に注意して実装する必要があります。
TextView.setText
をするとバックスペースなどが効かないため、直接操作する- 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する
- 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する
これらの注意点を実装した
TextWatcher.afterTextChanged
の例が以下です。override fun afterTextChanged(charSequence: Editable?) { if (charSequence == null || charSequence.isEmpty()) return // 未入力状態の場合は処理しない // (1) `TextView.setText`をするとバックスペースなどが効かないため、Editableを取得する val spannable: Spannable = textView.editableText ?: SpannableString(textView.text) // (2) 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する if (spannable.isEditing()) return // (3) 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する spannable.removeHashTagSpans() val matcher = PATTERN_HASH_TAG.matcher(charSequence) while (matcher.find()) { if (matcher.groupCount() != 2) continue // index[1]...ハッシュタグの`#`を含む文字列 // index[2]...ハッシュタグ内のコンテンツの文字列 val content = charSequence.subSequence(matcher.start(2), matcher.end(2)) val st = matcher.start(1) val ed = matcher.end(1) if (content.isContainsEmoji()) { // サロゲート文字は許可しない continue } spannable.setSpan( HashTagUnderlineSpan(), st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) callback?.let { callback -> spannable.setSpan( HashTagClickableSpan(callback, content), st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } HashTagForegroundColorSpan(Color.BLUE).run { spannable.setSpan(this, st, ed, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } if (spannable !is Editable) { textView.removeTextChangedListener(this) textView.setTextKeepState(spannable) textView.addTextChangedListener(this) } } }
TextView.setText
をするとバックスペースなどが効かないため、Editableを取得する
TextWatcher.afterTextChanged
内でTextView.setText
を行う場合、文字装飾の編集をする前後でTextWatcher
の除去と再設定を行なえば正常に動作するかと思われますが、この場合、バックスペースが正常に動作しない(長押し削除ができず、1文字づつの削除となってしまう)不具合に遭遇します。
このため、TextView.getEditableTextでEditableを取得する必要があります。This is the interface for text whose content and markup can be changed (as opposed to immutable text like Strings). If you make a DynamicLayout of an Editable, the layout will be reflowed as the text is changed.
Editableのドキュメントに記載の上記を参照するとわかる通り、コンテンツとマークアップが取得可能な場合に提供されるインターフェースであり、対象となる
TextView
が編集可能でない場合はTextView.getEditableTextでnull
が返却されます。// (1) `TextView.setText`をするとバックスペースなどが効かないため、Editableを取得する val spannable: Spannable = textView.editableText ?: SpannableString(textView.text)なお、
TextView.getText
でもSpannable
なインターフェースを取得できますが、次のように「やるとおこ?だよ」と言っているのでやめておいた方が無難でしょう。参考The content of the return value should not be modified. If you want a modifiable one, you should make your own copy first.
さらに、前述の例ではEditableが取得できないことを考慮して、この場合は文字装飾の編集をする前後で
TextWatcher
の除去と再設定を行なっています。if (spannable !is Editable) { textView.removeTextChangedListener(this) textView.setTextKeepState(spannable) textView.addTextChangedListener(this) }文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する
前述の例では、次のように編集中かどうかを
Spannable.isEditing
というメソッドで検査していますが、こちらは拡張関数として定義した関数です。// (1) 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する if (spannable.isEditing()) return
EditText
で日本語入力で変換中であるかを検査せずに文字装飾を行うと、変換前で文字入力が確定してしまいます。
たとえば、ローマ字入力などで「か」という文字を入力しようとする場合、k
、a
のキーを入力する必要がありますが、k
の段階で入力が確定してしまい、「kあ」と入力のまま変換ができなくなります。
これを防止するために現在のTextView
から取得したSpannable
からSpannable.SPAN_COMPOSINGのフラグがあるSpanが含まれているかを検査し、入力中かどうかを判定する必要があります。/** * 編集中判定処理 * * @return true...編集中、false...非編集中 */ private fun Spannable.isEditing(): Boolean { val spans = this.getSpans<Any>() return spans.any { this.getSpanFlags(it) and Spannable.SPAN_COMPOSING == Spannable.SPAN_COMPOSING } }文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する
Spannable.setSpan
は重複して設定されるでの注意点で説明したとおり、同じSpannable
のインスタンスに対してSpannable.setSpan
を複数回呼び出すと、見た目上は同じでも都度同じSpan増えていきます。val spannableString = SpannableString("ABCDEFGHIJK") spannableString.setSpan(UnderlineSpan(), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) println(spannableString.getSpans<UnderlineSpan>().size) // 1 spannableString.setSpan(UnderlineSpan(), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) println(spannableString.getSpans<UnderlineSpan>().size) // 2これを回避するために、ハッシュタグで使用する文字装飾は次のようなinterfaceとその実装を用意し、識別できるようにします。
/** * HashTagの文字装飾を示すInterface * 本クラス内で文字装飾されたものを識別するために使用する */ private interface HashTagSpan /** * [HashTagSpan]実装[ForegroundColorSpan] */ private class HashTagForegroundColorSpan(@ColorInt color: Int) : ForegroundColorSpan(color), HashTagSpan /** * [HashTagSpan]実装[UnderlineSpan] */ private class HashTagUnderlineSpan : UnderlineSpan(), HashTagSpan /** * [HashTagSpan]実装[ClickableSpan] */ private class HashTagClickableSpan( private val callback: (content: CharSequence) -> Unit, private val content: CharSequence) : ClickableSpan(), HashTagSpan { override fun onClick(widget: View) { callback(content) } }こうすることで、実際に今回の入力された内容に応じたハッシュタグ形式の文字装飾を行う前に、次のメソッドで
HashTagSpan
の実装のみを現在のSpannable
から除去することができます。
この前処理はとくに、他の処理でハイライトやアイコン画像表示などの文字装飾を行なっている場合に、それハッシュタグ形式の文字装飾以外を除去せずに、かつ、不必要に同じSpanを増殖させない効果があります。// (3) 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する spannableStringBuilder.removeHashTagSpans()/** * [HashTagSpan] 除去処理 * [TextView.setText] */ private fun Spannable.removeHashTagSpans() { val hashTagSpans = this.getSpans<HashTagSpan>() hashTagSpans.forEach { this.removeSpan(it) } }それ以外のTips
EditText
入力中にTextWatcher.onTextChanged
内でTextView.setText
を行うと、カーソルの位置が先頭に戻ってしまい、入力時におかしな挙動を示します。
具体的には「あい」と入力したはずが「いあ」のように表示されます。
このため、TextView.setTextKeepState
でTextView
の内容を設定してあげましょう
- 投稿日:2019-12-02T07:53:57+09:00
ViewModel + LiveData + Databindingでボタンの活性制御
今回の仕様
メールアドレス = 入力されている
パスワード = 6文字以上この2つが満たされているときだけログインボタンが押せるような、よくあるログイン画面を作りたいと思います。
環境
Android Studio 3.5.2
その他はbuild.gradle参照してください。必要な知識
- Android Studioを使える
- kotlinを書ける
- activityとfragmentを使ったことがある
リポジトリ
iwahara/button_enabled_sample: for Advent Calendar 2019
はじめに
今回はAndroidXを使うので、プロジェクトを作る際にそれを有効化します。
Fragment+ViewModelを選ぶとはじめから画面表示まで揃ってるのでやりやすいです。
プロジェクトの設定でAndroidXを使う設定を忘れずに。
app/build.gradle
DataBindingを使うので有効化します。
kotlin-kapt
プラグインとdataBindingの有効化を設定します。//追加 apply plugin: 'kotlin-kapt' android { //追加 dataBinding { enabled = true } }また、依存ライブラリは以下の通りです。
dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.core:core-ktx:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' implementation 'com.google.android.material:material:1.0.0' testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' }MainViewModelクラス
MainFragmentのViewModelです。今回の肝となるクラスです。
表示に必要なロジックを書くクラスになります。
詳しい解説はあとにして、まずはクラス全体のコードです。全体package com.sample.buttonenabled.ui.main import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel class MainViewModel : ViewModel() { val mailAddress = MutableLiveData<String>("") val password = MutableLiveData<String>("") val isSaveMailAddress = MutableLiveData<Boolean>(false) val isButtonLoginEnabled = MediatorLiveData<Boolean>() init { val observer = Observer<String> { val mail = this.mailAddress.value ?: "" val password = this.password.value ?: "" this.isButtonLoginEnabled.value = mail.isNotEmpty() && password.trim().length >= 6 } isButtonLoginEnabled.addSource(mailAddress, observer) isButtonLoginEnabled.addSource(password, observer) } }解説
Observableな変数を定義
ここで各画面変数を
LiveData
として宣言します。
LiveData
を使うことで、画面にて入力があった際に自動的にこれらの変数に値が格納されるようになります(別途layoutでの設定が必要になりますが)。基本的には
MutableLiveData
ですが、最後のisButtonLoginEnabled
だけMediatorLiveData
なので注意!val mailAddress = MutableLiveData<String>("") val password = MutableLiveData<String>("") val isSaveMailAddress = MutableLiveData<Boolean>(false) val isButtonLoginEnabled = MediatorLiveData<Boolean>()initでMediatorLiveDataの設定を行う
MediatorLiveDataは複数のLiveDataをもとに値を更新したいときに使います。
今回であれば、メールアドレスとパスワードをもとに、ログインボタンの活性制御をしたい感じですね。
そのためには、まずandroidx.lifecycle.Observer
を使って動作を定義し、isButtonLoginEnabled
に監視したい値とともに設定する必要があります。
なお、Observerの型パラメータは監視されるLiveDataの型パラメータと一致する必要があります。
今回は両方ともStringだったので同じObserverを使いまわしていますが、もし違う場合はObserverを別で定義する必要があります。init { val observer = Observer<String> { val mail = this.mailAddress.value ?: "" val password = this.password.value ?: "" this.isButtonLoginEnabled.value = mail.isNotEmpty() && password.trim().length >= 6 } isButtonLoginEnabled.addSource(mailAddress, observer) isButtonLoginEnabled.addSource(password, observer) }layout/main_fragment.xml
ViewModelをDataBindingして使うための設定が必要となります。
こちらも詳しい解説はあとにして、まずはコード全体です。全体<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="viewmodel" type="com.sample.buttonenabled.ui.main.MainViewModel" /> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".ui.login.LoginFragment"> <com.google.android.material.textfield.TextInputLayout android:id="@+id/layout_mail_address" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <EditText android:id="@+id/edit_email" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="メールアドレス" android:text="@={viewmodel.mailAddress}" android:inputType="textEmailAddress" android:maxLines="1" /> </com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputLayout android:id="@+id/layout_password" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/layout_mail_address"> <EditText android:id="@+id/edit_password" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="パスワード" android:inputType="textPassword" android:text="@={viewmodel.password}" android:maxLines="1" /> </com.google.android.material.textfield.TextInputLayout> <androidx.appcompat.widget.AppCompatCheckBox android:id="@+id/check_save_mail_address" android:layout_width="0dp" android:layout_height="wrap_content" android:text="メールアドレスを保存する" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/layout_password" android:checked="@={viewmodel.saveMailAddress}" /> <Button android:id="@+id/button_login" android:enabled="@{viewmodel.isButtonLoginEnabled}" android:layout_width="0dp" android:layout_height="wrap_content" android:text="ログイン" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/check_save_mail_address" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>解説
画面で使うViewModelの定義
まずは、ルート要素を
layout
タグにし、その中でdata
とConstraintLayout
を定義するようにします。
dataの子要素としてvariableを定義し、先程作ったMainViewModel
を指定します。
nameは何でも良いのですが、ここではわかりやすくviewmodel
としています。<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="viewmodel" type="com.sample.buttonenabled.ui.main.MainViewModel" /> </data>画面項目にViewModelの該当するLiveDataをBindする
android:text="@={viewmodel.mailAddress}"
でviewModelのMailAdressをbindしています。
これで、メールアドレスの入力欄に何か入力されたら自動でViewModelにも設定されるようになります。
パスワードも同様に設定します。<com.google.android.material.textfield.TextInputLayout android:id="@+id/layout_mail_address" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <EditText android:id="@+id/edit_email" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="メールアドレス" android:text="@={viewmodel.mailAddress}" android:inputType="textEmailAddress" android:maxLines="1" /> </com.google.android.material.textfield.TextInputLayout>Buttonの活性制御にViewModelのisButtonLoginEnabledをBindする
android:enabled="@{viewmodel.isButtonLoginEnabled}"
をenabledにbindすることで、活性制御を自動で行うようにします。<Button android:id="@+id/button_login" android:enabled="@{viewmodel.isButtonLoginEnabled}" android:layout_width="0dp" android:layout_height="wrap_content" android:text="ログイン" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/check_save_mail_address" />MainFragmentクラス
ViewModelを作って保持し、ログインボタンを押したときの処理を書きます。
ロジックはViewModelに持っているので、ほぼ表示とイベント設定に関することのみ書きます。詳しい解説はあとにして、まずはクラス全体のコードです。
全体package com.sample.buttonenabled.ui.main import androidx.lifecycle.ViewModelProviders import android.os.Bundle import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.databinding.DataBindingUtil import com.sample.buttonenabled.R import com.sample.buttonenabled.databinding.MainFragmentBinding import kotlinx.android.synthetic.main.main_fragment.* class MainFragment : Fragment() { private lateinit var binding: MainFragmentBinding companion object { fun newInstance() = MainFragment() } private val viewModel: MainViewModel by lazy { ViewModelProviders.of(this).get(MainViewModel::class.java) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = DataBindingUtil.inflate(inflater, R.layout.main_fragment, container, false) binding.lifecycleOwner = viewLifecycleOwner binding.viewmodel = this.viewModel return binding.root } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) this.button_login.setOnClickListener { val email = this.viewModel.mailAddress.value val pass = this.viewModel.password.value Toast.makeText(requireContext(), "${email} : ${pass}", Toast.LENGTH_SHORT).show() } } }解説
ViewModelの取得
ViewModelは普通にインスタンス化するのではなく、ViewModelProvidersから取得するようにします。
private val viewModel: MainViewModel by lazy { ViewModelProviders.of(this).get(MainViewModel::class.java) }なお、2.2.0からは
ViewModelProviders.of
はdeprecatedになります。
推奨される方法は以下の記事を参考にしてください。Android Jetpack(AndroidXライブラリ)の最近の更新 - Qiita
DataBindingの設定
MainFragmentBinding
は事前にリビルドしないと出てこないので、一度リビルドしましょう。
inflater
の代わりにDataBindingUtil.inflate
を使います。
その際にlifecycleOnwerとviewmodelを設定します。こうすると、ViewModelのライフサイクルが適切に処理されるようになります。private lateinit var binding: MainFragmentBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = DataBindingUtil.inflate(inflater, R.layout.main_fragment, container, false) binding.lifecycleOwner = viewLifecycleOwner binding.viewmodel = this.viewModel return binding.root }実際に動かす
ここまで来たら動くようになっているはずです。
ここまでボイラープレートをなくすことが出来るとは、便利な世の中になりましたね。
- 投稿日:2019-12-02T02:00:43+09:00
OkHttpを使った場合にWebViewのJavascriptInterfaceはどう呼ばれるか軽く調べてみた
AndroidのWebViewでは、addJavascriptInterfaceという、JavaオブジェクトをWebViewに埋め込む機能があります。これにより、WebView内で表示しているWebページに埋め込まれたJavascriptから、当該Javaオブジェクトで定義されたメソッドを呼び出せるようになります。
今回は、WebViewでのHTTP通信にOkHttpを利用した時、WebViewのコールバックメソッドはどう呼ばれるか軽く調べてみました。
読むのがめんどくさい方は最後の「まとめ」だけを読んでいただいてもよいです。WebViewを使用するActivityの実装
WebViewを使用するActivityのソースコードはこんな感じです。
サンプルコードがお粗末なのはご愛嬌です。MainActivity.ktclass MainActivity : AppCompatActivity() { companion object { const val TAG = "WebViewTest_" const val TAG_OKHTTP = "OkHttp_WebViewTest" const val TAG_TID = "Tid_webView" const val JS_INTERFACE_NAME = "Android" const val INITIAL_ENDPOINT = "http://${sever_ipAddress}:10000/" } private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_main) initWebView() } @SuppressLint("JavascriptInterface", "SetJavaScriptEnabled") fun initWebView() { Log.d(TAG_TID, "MainActivity#initWebView called. tid = " + Thread.currentThread().id) binding.webView.apply { settings.javaScriptEnabled = true addJavascriptInterface(this@MainActivity, JS_INTERFACE_NAME) webViewClient = object: WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { Log.d(TAG, "shouldOverrideUrlLoading called. url = " + request?.url.toString()) return super.shouldOverrideUrlLoading(view, request) } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { Log.d(TAG, "onPageStarted called. url = " + url!!) super.onPageStarted(view, url, favicon) } override fun onPageFinished(view: WebView?, url: String?) { Log.d(TAG, "onPageFinished called. url = " + url!!) super.onPageFinished(view, url) } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { Log.d(TAG, "onReceivedError error = " + error!!) super.onReceivedError(view, request, error) } override fun onLoadResource(view: WebView?, url: String?) { Log.d(TAG, "onLoadResource called. url = " + url!!) super.onLoadResource(view, url) } override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { Log.d(TAG_TID, "shouldInterceptRequest called. tid = " + Thread.currentThread().id) if (!request?.url.toString().endsWith("/favicon.ico")) { Log.d(TAG, "shouldInterceptRequest. url = " + request?.url.toString()) } val latch = CountDownLatch(1) var res: InputStream? = null val call = if (request!!.url.path!!.endsWith("/getJsBySrc") or request.url.path!!.endsWith("/doHttpReqFromJsCode.js") ) { createOkHttpClient().newCall(Request.Builder().url(request.url.toString()).method("POST", RequestBody.create(null, "hoge")).build()) } else { createOkHttpClient().newCall(Request.Builder().url(request.url.toString()).build()) } call.enqueue(object: Callback { override fun onFailure(call: Call, e: IOException) { //Log.d(TAG_OKHTTP, "okhttp Callback#onFailure called. callUrl = " + call.request().url()) Log.d(TAG_OKHTTP, "okhttp Callback#onFailure called. error = " + e.message.toString()) latch.countDown() } override fun onResponse(call: Call, response: Response) { //Log.d(TAG_OKHTTP, "okhttp Callback#onResponse called. callUrl = " + call.request().url()) Log.d(TAG_OKHTTP, "okhttp Callback#onResponse called. resUrl = " + response.request().url()) res = response.body()?.byteStream() latch.countDown() } }) latch.await() return WebResourceResponse( "text/html", "UTF-8", res ) } } loadUrl(INITIAL_ENDPOINT) } } private val cookieStore = HashMap<String, MutableList<Cookie>>() fun createOkHttpClient(): OkHttpClient { return OkHttpClient.Builder() .addNetworkInterceptor { chain -> Log.d(TAG_TID, "okhttp intercepted: tid = " + Thread.currentThread().id) Log.d(TAG_OKHTTP, "okhttp intercepted: " + chain.request().url().toString()) chain.proceed(chain.request()) } //.connectTimeout(1, TimeUnit.MILLISECONDS) .cookieJar(object: CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) { cookieStore[url.host()] = cookies } override fun loadForRequest(url: HttpUrl): MutableList<Cookie> { val cookies = cookieStore[url.host()] return cookies ?: ArrayList() } }) .build() } @JavascriptInterface fun showToast(str: String) { Toast.makeText(this, str, Toast.LENGTH_LONG).show() } }ポイントは以下。
- MainActivityをJavascriptInterfaceとして「Android」という名前で追加
- showToastというメソッドをWebView内からJavascriptにて実行できるよう定義
- 各コールバックが呼ばれるタイミングでLogを出力し、どのようにHTTP通信が制御されているのか確認
サーバ側の実装
サーバ側の実装は以下です。今回はクライアントからrootのエンドポイントにアクセスされることを前提としています。
app/Main.hs1 {-# LANGUAGE OverloadedStrings #-} 2 module Main where 3 4 import Network.Wai.Middleware.Static 5 import Network.HTTP.Types.Status 6 import Web.Spock 7 import Web.Spock.Config 8 9 import Control.Monad.Trans 10 import qualified Data.Text as T 11 12 data MySession = MySession { msUserId :: Maybe String } 13 14 main :: IO () 15 main = do 16 spockCfg <- defaultSpockCfg (MySession Nothing) PCNoDatabase () 17 runSpock 10000 (spock spockCfg app) 18 19 app :: SpockM () MySession () () 20 app = do 21 middleware $ staticPolicy (addBase "static") 22 get root $ 23 (modifySession $ \sess -> sess { msUserId = Just "dummy" }) >> redirect "http://${server_ipAddress}:10000/submitFormByJs" 24 get ("submitFormByJs") $ do 25 sess <- readSession 26 case msUserId sess of 27 Nothing -> setStatus status401 >> text "No session" 28 Just _ -> (liftIO . readFile $ "static/submitFormByJs.html") >>= html . T.pack 29 post ("getJsBySrc") $ 30 (liftIO . readFile $ "static/getJsBySrc.html") >>= html . T.pack 31 post ("doHttpReqFromJsCode.js") $ 32 file "application/javascript" "static/doHttpReqFromJsCode.js" 33 get ("fromXMLHttpReq") $ 34 (liftIO . readFile $ "static/fromXMLHttpReq.html") >>= html . T.pack 35 get ("onReceivedXHRResAndSetLocationHref") $ 36 (liftIO . readFile $ "static/onReceivedXHRResAndSetLocationHref.html") >>= html . T.pack 37 get ("favicon.ico") $ 38 file "image/png" "favicon.ico" ~通信フローまとめ
上記のWebサーバのrootエンドポイントアクセスした際の通信フローをまとめると、以下のような感じになります。
- root
- "submitFormByJs"
- "getJsBySrc"
- "doHttpReqFromJsCode.js"
- "fromXMLHttpReq"
- "onReceivedXHRResAndSetLocationHref"
1. root
HTTPステータス302で"submitFormByJs"へリダイレクト
2. "submitFormByJs"
<form>タグにより、"getJsBySrc"エンドポイントへのPOSTリクエストを送出するhtmlを応答。
WebViewがこのHTMLを解釈するときに、上記POSTリクエストが実行される。submitFormByJs.html1 <html> 2 <body> 3 <script type="text/javascript"> 4 const ua = window.navigator.userAgent 5 if (ua.includes("Android")) { 6 Android.showToast("A post request is going to be sent from a <form> tag."); 7 } 8 </script> 9 <script type="text/javascript"> 10 function doPost() { 11 document.form1.method = "post"; 12 document.form1.submit(); 13 } 14 </script> 15 <form name="form1" action="http://${server_ip}:10000/getJsBySrc" method="post"> 16 </form> 17 <h1>submitFormByJs</h1> 18 <script type="text/javascript"> 19 doPost(); 20 </script> 21 22 </body> 23 </html> ~3. "getJsBySrc"
<script>タグの実行により、エンドポイント"doHttpReqFromJsCode"へのGETリクエストを行うhtmlを応答。
getJsBySrc.html1 <html> 2 <body> 3 <h1>GetJSBySrc</h1> 4 <!-- <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script> --> 5 <script type="text/javascript"> 6 const ua = window.navigator.userAgent 7 if (ua.includes("Android")) { 8 Android.showToast("A javascript file is going to be loaded by <script ... src=...>"); 9 } 10 </script> 11 <script type="text/javascript" src="http://${server_ip}:10000/doHttpReqFromJsCode.js"></script> 12 </body> 13 </html>4. "doHttpReqFromJsCode.js"
XMLHttpReqeustにより、エンドポイント"fromXMLHttpReq"へHTTP通信を行うJavascriptを応答。
上記通信が成功したときに、location.hrefによりエンドポイント"onReceivedXHRResAndSetLocationHref"へGET通信によるリダイレクトをさせる。doHttpReqFromJsCode.js1 const xhr = new XMLHttpRequest(); 2 3 xhr.open('POST', 'http://${server_ip}:10000/fromXMLHttpReq.html'); 4 xhr.send(); 5 6 xhr.onreadystatechange = function() { 7 if(xhr.readyState === 4 && xhr.status === 200) { 8 const ua = window.navigator.userAgent 9 if (ua.includes("Android")) { 10 Android.showToast("doHttpReqFromJsCode.js"); 11 } 12 location.href="http://${server_ip}:10000/onReceivedXHRResAndSetLocationHref" 13 } 14 }5. "fromXMLHttpReq"
doHttpReqFromJsCode.js内の、XMLHttpRequest実行によりアクセスされる。
fromXMLHttpReq.html1 <html> 2 <body> 3 <h1>XMLHttpRequest</h1> 4 <script type="text/javascript"> 5 const ua = window.navigator.userAgent 6 if (ua.includes("Android")) { 7 Android.showToast("XMLHttpRequest succeeded. location.href is going to be modified."); 8 } 9 </script> 10 </body> 11 </html>6. ""onReceivedXHRResAndSetLocationHref"
doHttpReqFromJsCode.jsによるXMLHttpRequest通信が成功したときアクセスされる。
onReceivedXHRResAndSetLocationHref.html1 <html> 2 <body> 3 <script type="text/javascript"> 4 const ua = window.navigator.userAgent 5 if (ua.includes("Android")) { 6 Android.showToast("location.href has been modified."); 7 } 8 </script> 9 <h1>Received XMLHttpRequest's response</h1> 10 </body> 11 </html>ログ出力結果
WebViewでloadUrl("http://${server_ip}:10000/")した際のログ出力は以下です。
※192.168.100.151は筆者のローカルマシンのIPアドレスです。タグ「WebViewTest_」でフィルター1 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/ 2 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/ 3 D/WebViewTest_: JavascriptInterface method called. str = A post request is going to be sent from a <form> tag. 4 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/getJsBySrc 5 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/ 6 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/getJsBySrc 7 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/doHttpReqFromJsCode.js 8 D/WebViewTest_: JavascriptInterface method called. str = A javascript file is going to be loaded by <script ... src=...> 9 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/fromXMLHttpReq.html 10 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/getJsBySrc 11 D/WebViewTest_: JavascriptInterface method called. str = doHttpReqFromJsCode.js 12 D/WebViewTest_: shouldOverrideUrlLoading called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 13 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 14 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 15 D/WebViewTest_: JavascriptInterface method called. str = location.href has been modified. 16 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHrefOkHttp通信の箇所のLog出力結果は以下。
タグ「OkHttp_WebViewTest」でフィルター18 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/ 19 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/submitFormByJs 20 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/submitFormByJs 21 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/getJsBySrc 22 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico 23 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/getJsBySrc 24 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.ico 25 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/doHttpReqFromJsCode.js 26 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/doHttpReqFromJsCode.js 27 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/fromXMLHttpReq.html 28 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/fromXMLHttpReq.html 29 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico 30 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.ico 31 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 32 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 33 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico 34 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.icoまとめ
上記結果をまとめてみます。(あくまで筆者のローカル環境で検証した結果のため、挙動に差異が出る可能性あり。)
WebViewのshouldInterceptRequestが呼ばれるパターン
- HTML内の<form>タグで発生するHTTP通信
- HTML内の<script type="text/javascript" src="...">で発生するHTTP通信
- HTTP通信によるJavascript取得(GETリクエストで"xxx.js"にアクセス)
- JavascriptでのXMLHttpRequestでのHTTP通信
- HTML内でのlocation.href変更によるリダイレクト
HTTPステータス302によるリダイレクトではWebViewのshouldInterceptRequestは呼ばれなかった。
OkHttpは(デフォルトでは)HTTPステータス302リダイレクトをよしなにハンドリングして、レスポンスを取得するため、rootエンドポイントからのリダイレクト先"submitFormByJs"に対して、WebViewのshouldInterceptRequestは上記の実装では呼ばれない。JavascriptInterfaceメソッドが呼ばれるパターン
- HTML内の<form>タグで発生するHTTP通信
- HTML内の<script type="text/javascript" src="...">で発生するHTTP通信
- HTTP通信によるJavascript取得(GETリクエストで"xxx.js"にアクセス)
- JavascriptでのXMLHttpRequestでのHTTP通信
- HTML内でのlocation.href変更によるリダイレクト
上記の実装では、XMLHttpRequest通信でアクセスする"fromXMLHttpReq"でのJavascriptInterfaceメソッド呼び出しは実行されなかった。レスポンスをWebViewで表示しようとすれば呼ばれるかもしれませんが、時間なかったので調べてません。
その他JavascriptInterface呼び出しについて思うこと
・Android Developers公式サイトにも記載の通り、Javascriptを利用したWebView <-> Native間のデータ連携はセキュリティリスクが伴うので、なるべくやりたくない。
・上記の通り、リダイレクトが絡むJavascriptInterfaceの実行制御はトリッキーな実装になりがちなので、既存のWebサービスがあるからといって、WebAPIの作成を怠って、安易にJavascriptInterfaceを採用するべきではないでしょう。WebViewの主目的はWebコンテンツを表示することのはずです。WebAPI代わりに使ったら、無駄なHTML評価や画像取得とか色々無駄な処理が走っちゃいますね。無駄な処理を省くには結局、スクラッチレベルでHTMLを解析し、必要な処理だけ通信を行う処理を書く必要がありそうです(極力、スマホアプリで使用する専用のWebAPIを作りましょう)。
・WebViewは文字通りViewの一種なので、単純なWebAPIのように、任意のタイミングでHTTP通信実行とか、複数画面にまたがるデータ保持とか難しい。AndroidシステムのWindowにアタッチした状態でないと、WebViewによるHTMLの評価は走らないのではないでしょうか(よく知りませんが)。AndroidコンポーネントであるServiceクラスにWebViewを定義してHTMLの評価ができるみたいな情報もありますが、できたとしてもバグが出そうですね。商用のアプリでは採用すべきではないでしょう。(スマホアプリで使用する専用のWebAPIを作りましょう)
・JavascriptInterfaceの使用は、基本、既存のシステム設計上止むを得ない場合のみに、セキュリティを十分に考慮した上で使用するに留めた方が良い(スマホアプリで使用する専用のWebAPIを作りましょう)
- 投稿日:2019-12-02T00:00:23+09:00
AndroidでFFmpegを使って音声ファイルを解析・変換する
はじめに
FFmpegと聞くと、動画や音声を解析・変換することができたりと機能豊富で非常に難しそうなイメージですよね。ですが、音声だけに絞ってみれば意外と簡単(?)に使いこなすことができるんじゃないかと思います。
今回は音声解析・変換の基礎的な使い方をAndroidで実践してみたいと思います!
(サンプルコードはこちら)FFmpegのインストール
AndroidのFFmpegライブラリはいくつかありますが一番開発が活発なmobile-ffmpegを使ってみます。
mobile-ffmpegはAndroidの他にiOS, tvOSで利用でき、メインバージョンとLTSバージョンがあります。
(今回ご紹介するコマンドはどちらのバージョンでも使えます)app/build.gradle// NOTE: フル機能が使えるがサポートされるAPIレベルが24以上 implementation 'com.arthenica:mobile-ffmpeg-full:4.2.2' // or // LTS版はAPIレベル16以上で使えるが機能制限版 implementation 'com.arthenica:mobile-ffmpeg-full:4.2.2.LTS'FFmpegを使ってみる
使い方は非常に簡単。単純に実行させたいだけならこれだけでOKです。
MainActivity.ktoverride fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // FFmpegのバージョンを確認する FFmpeg.execute("-version") val rc = FFmpeg.getLastReturnCode() val output = FFmpeg.getLastCommandOutput() when (rc) { FFmpeg.RETURN_CODE_SUCCESS -> { Log.i("FFmpeg", "Success!") } FFmpeg.RETURN_CODE_CANCEL -> { Log.e("FFmpeg", output) } else -> { Log.e("FFmpeg", output) } } }では今度は本題の音声ファイルを解析してみます。
FFmpegのvolumedetectフィルターを使って解析してみます。MainActivity.ktoverride fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) button.setOnClickListener { // NOTE: filesDir.pathの場所に音声ファイルを配置 val filename = filesDir.path + File.separator + "inputFile.wav" FFmpeg.execute("-i $filename -af volumedetect -f null NULL") val rc = FFmpeg.getLastReturnCode() val output = FFmpeg.getLastCommandOutput() when (rc) { FFmpeg.RETURN_CODE_SUCCESS -> { text.text = "FFmpeg Success!\n $output" } FFmpeg.RETURN_CODE_CANCEL -> { text.text = "FFmpeg Cancel\n $output" } else -> { text.text = "FFmpeg Error\n $output" } } } }何やら色々と出力されました。
この中で自分なりに重要だと思っているのが下記3項目です。
項目 概要 time 音声ファイルの長さ。フォーマットは00:00:00.00 mean_volume 平均音量。音声ファイル全体の平均音量をdBで表す。最大は0.0dB max_volume 最大音量。音声ファイル全体の中で最大音量をdBで表す。最大は0.0dB 項目の抽出は下記のような感じでやります。
音声ファイル解析結果の抽出val DELIMITER_TIME = "time=" val DELIMITER_SPACE = " " val DELIMITER_MEAN_VOLUME = "mean_volume: " val DELIMITER_MAX_VOLUME = "max_volume: " // NOTE: time抽出 var resultTime = "" if (Regex(DELIMITER_TIME).containsMatchIn(output)) { val times = output.split(DELIMITER_TIME) // NOTE: timeが2つあるが2つ目が音声ファイルの長さを表す。 resultTime = if (times.size == 3) { times[2].split(DELIMITER_SPACE)[0] } else { times[1].split(DELIMITER_SPACE)[0] } } // NOTE: mean_volume抽出 var meanVolume: Double? = null if (Regex(DELIMITER_MEAN_VOLUME).containsMatchIn(output)) { meanVolume = try { output.split(DELIMITER_MEAN_VOLUME)[1].split(DELIMITER_SPACE)[0].toDouble() } catch (e: NumberFormatException) { null } } // NOTE: max_volume抽出 var maxVolume: Double? = null if (Regex(DELIMITER_MAX_VOLUME).containsMatchIn(output)) { maxVolume = try { output.split(DELIMITER_MAX_VOLUME)[1].split(DELIMITER_SPACE)[0].toDouble() } catch (e: NumberFormatException) { null } }ちなみに今回サンプルに使った音声ファイルは
time=00:00:03.16
、mean_volume=-41.8 dB
、max_volume=-17.9 dB
でした。これらの項目は色々使い道あると思いますし(音声ファイルの長さが短い場合はエラーにする、音量小さい場合はエラーにするなどのバリデーション)、後述する音声ファイルのノーマライズにも使えます。
音声ファイルのノーマライズ
ノーマライズとは「音量レベルの正規化」です。
と言われても分かりづらいと思うのでもう少しわかりやすく言うと、音量を最大まで上げる処理のことです。そのために必要な項目が前述のvolumedetectフィルターで取得したmax_volume
で、このピーク時の音量「max_volume」を丁度0dB1になるように設定します。この時、0dBを超えてしまうとデジタルクリップ(音割れ)が発生するため超えないように注意してください。
また、音声ファイルはデジタルデータなので小さい音をそのまま大きくすれば音量は大きくなりますが音質は劣化します。なので高音質を求める方には向きません。ノーマライズmaxVolume?.let { FFmpeg.execute("-y -i $inputFile -af volume=${-it} $outputFile") val rcNormalize = FFmpeg.getLastReturnCode() val outputNormalize = FFmpeg.getLastCommandOutput() fFmpegResult.errorReason = when (rcNormalize) { RETURN_CODE_SUCCESS -> { null } else -> { FFmpegErrorReason.FFMPEG_NORMALIZE } } }音声ファイルのノイズ除去
マイクの良し悪しによりますがマイクで音声を録音すると生活音などノイズを拾ってしまいます。ノイズキャンセル機能を持ったマイクや、指向性マイクなど使えばある程度抑えることはできると思いますがソフトウェア的に後からノイズを除去することもできます。
superequalizerフィルター
音声を18の周波数帯に分けてゲイン(シグナルの強さ)を調整することが出来ます。たとえば人の声は100Hz ~ 2000Hz(ソプラノ歌手は2000Hzまで出せるらしい)なのでそれ以外をノイズとして抑えるようゲインを設定します。
FFmpeg.execute("-y -i $inputFile -af superequalizer=1b=0:2b=0:3b=1:4b=1:5b=1:6b=1:7b=1:8b=1:9b=1:10b=1:11b=1:12b=0:13b=0:14b=0:15b=0:16b=0:17b=0:18b=0 $outputFile")highpass, lowpassフィルター
音声の周波数を上限、下限を設定することで除外することが出来ます。除外する音声の上限、下限値が決まっている場合はこちらのほうが楽です。
FFmpeg.execute("-y -i $inputFile -af \"highpass=f=100, lowpass=f=2000\" $outputFile")anlmdnフィルター
Non-local Meansアルゴリズムを用いたフィルターです。基本的にはドキュメントの通りで、個人的にはノイズ除去強度は0.01くらいがバランスよくておすすめです。
FFmpeg.execute("-y -i $inputFile -af anlmdn=s=0.01 $outputFile")最後に
FFmpegには他にも様々なフィルターが用意されています。これでさらに動画に関する編集もできるのでほんとに多機能ですね~。いずれは動画周りもやってみたいです。
参考サイト
ニコラボ - 18の周波数帯に分けてゲインを調整する
FFmpeg Filters Documentation
ヘッドホンアンプの最大音量が0 dB? オーディオに関する素朴な疑問あれこれ
自分みたいな素人だと最大音量が0dBってどういうこと?って思ってしまいますが、今回のdBが意味するところは例えば100dBの音量が出るスピーカーがあるとすると、そこからの減算値のことを指します。0dBであれば減算すること無くそのまま100dBでスピーカーから音が流れるということです。 ↩