20191202のAndroidに関する記事は10件です。

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が変更されてしまっていないか

備忘録的に活用したいと思います

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ViewPager2 + TabLayout + DataBinding

はじめに

DMMグループ Advent Calendar 20193日目を担当します!@mii-chang です!

2018年新卒入社でDMMにJOINし、今は AQUIZ というクイズアプリのAndroidアプリを開発しています:muscle::fire:
https://aquiz.jp/

クイズに答えてお金がもらえる!:moneybag:面白いアプリなのでぜひ皆さん遊んでくださいね〜〜!:raising_hand:?

さて、今回はJetpackに新しく追加されたViewPager2について書いていきたいと思います!:rocket::rocket::rocket:

ViewPager2 とは

従来のViewPagerの進化版のViewPager2
今までは、各FragmentをPagerAdapterを使って表示切り替えをしていました。
ViewPager2では、PagerAdapterの代わりにRecyclerView.Adapterを使います。
リファレンス

何がいいの?

今までは、PagerAdapterに切り替えたいFragmentをそれぞれセットしていましたが、
新しいViewPager2では、画面切り替えをRecyclerViewベースでやってくれるので、Viewのレイアウトを作っておけばそれを再利用してくれます。
要するに、Fragmentが1つで良くなったのです:tada:
また、RecyclerViewベースなので、RecyclerViewの知識があれば扱いやすいのも魅力ですね:sunny:

使ってみよう

今回は、Databindingを一緒に使って、簡単なサンプルを作ってみます。

導入

app配下のbuild.gradleに以下を追加します

build.gradle
implementation "androidx.viewpager2:viewpager2:1.0.0-beta04"

バージョン情報は公式リファレンスを見てください!:eyes:

実装

サンプルとして、クリスマスアイテムの画像と名前のリストを、ViewPagar2を使って表示してみます:christmas_tree::santa:

レイアウト

今回は、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

TabLayoutのリファレンス

tabGravity

tabGravity は、インジケーター全体の横幅を決められます。

center fill
center fill
真ん中寄せになる 横幅いっぱいにインジケーターが広がる
tabIndicatorColor

tabIndicatorColor は、その名の通り、インジケーターの色を設定できます。

tabIndicatorGravity

tabIndicatorGravityは、TabLayout内でのインジケーターの位置を指定できます。(View全体の背景が緑、インジケーターの色がピンク)

top stretch
device-2019-12-02-204335.png device-2019-12-02-204356.png
center bottom
device-2019-12-02-204433.png device-2019-12-02-204634.png

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.kt
internal object ChristmasBindingAdapter {
    @JvmStatic
    @BindingAdapter("android:src")
    fun setDrawable(
        imageView: ImageView?,
        drawable: Drawable?
    ) {
        if (imageView == null) return
        drawable?.let {
            imageView.setImageDrawable(drawable)
        }
    }
}

バインドするデータクラス

今回は、Christmasデータクラスを作って、この内容をバインドさせます

Christmas.kt
data class Christmas(
    val title: String,
    val drawable: Drawable?
)

ViewHolder

レイアウトに定義したデータタグに、Christmasデータクラスの内容を渡すために、RecyclerView.ViewHolderを継承したViewHolderクラスを作ります。

ItemViewHolder.kt
internal 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.kt
internal 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.kt
class 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なので結構スムーズですね:bulb:

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上にも情報が少なかったので、どなたかの役に立てれば幸いです:christmas_tree:

サンプルのリポジトリをGitHubに置いておいたので全体が見たい場合はこちらをどうぞ!
:point_down::point_down::point_down:
https://github.com/mii-chang/ViewPager2Sample
:point_up_2::point_up_2::point_up_2:

DMMグループ Advent Calendar 2019明日の担当は、 @karayok さんです!:santa:

参考

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

スマホアプリのコマンドビルドまとめ(Xamarin編)

前回の『スマホアプリのコマンドビルドまとめ(iOS編)』に引き続き、今回はXamarinのCI環境を作る際に溜まった知見をまとめた記事です。 前提として、Mac mini/Mac Pro(ゴミ箱)な…
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初めてのConstraintLayoutでChainStyleを利用してみた

はじめに

新卒で初めてAndroidアプリの実装を行いました。
その際、ConstraintLayoutの利用方法について調べてみたので、
アウトプットとして同じ初心者の方の参考になれば良いと思い記事にしました。

目次

  • ConstraintLayoutとは
  • Chainの設定方法
  • ChainStyleの種類
  • Weightの制約

ConstraintLayoutとは

ConstraintLayoutは、ウィジェットを柔軟な方法で配置およびサイズ設定できるようにするものです。以下のように様々な制約をつけることができます。

  • 相対位置決め
  • マージン
  • センタリング位置決め
  • 円形位置決め
  • 可視性の動作
  • 寸法拘束
  • チェーン
  • 仮想ヘルパーオブジェクト
  • オプティマイザ

Chainの設定方法

output.gif

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" />

spred.png

Spread inside

両端のマージンは取らずに、中のマージンをとり均等に配置されます。

 <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintHorizontal_chainStyle="spread_inside" />

spred_inside.png

Packed

一つの要素としてまとめられ、マージンをとります。

 <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintHorizontal_chainStyle="packed" />

packed.png

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>

※ 画像は1:2:3で設定したものです。
weight.png

参考

https://developer.android.com/reference/android/support/constraint/ConstraintLayout.html
https://medium.com/@nomanr/constraintlayout-chains-4f3b58ea15bb

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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の使い方(配信側)

ad001.png

GUIでの操作はFirebaseのコンソールサイト上から行います。配信したいアプリのapkをドラッグし、配信したいユーザーのメールアドレスを設定するだけです。予めテスターのグループを作成することで一括配信もできますし、招待リンクを作成して不特定のユーザーに配信することも可能です。

ad002.png

招待リンクでは、メールアドレスのドメインに制限をかけることができます。

ad003.png

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.gradle
buildscript {
    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.gradle
apply 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横のコンボボックスから切り替えることになります。

ad004.png

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Android リアルタイム入力でハッシュタグ形式に文字装飾するTIPS

本稿の目的

#から始まるハッシュタグの部分の色が変わり、タップするとハッシュタグの内容に応じたフィードの検索などを行う機能は最近ではあたりまえのUXになっています。

image.png

Androidでの文字装飾はUnderlineSpanや、ForegroundColorSpanを組み合わせれば簡単に実装できますが、文字装飾した部分をクリックできるようにしたり、TwitterやFacebookのようにリアルタイムで入力した内容に応じて文字装飾を行うためにはどのように実装するべきかを説明します。

iOSについても記述しています。よろしければどうぞ。

ラベルにハッシュタグの形式に相当する部分を文字装飾する&クリックできるようにする

ハッシュタグの形式に相当する部分を正規表現をして抽出する

文字装飾するにはSpannableを実装したCharSequenceTextViewに設定します。

任意の文字列、もしくはすでに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を実装したインスタンスcharSequenceSpannableも実装しており、すでに何らかの文字装飾が行われている場合はその文字装飾も引き継がれます。
image.png

このため、前述のコード例での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で前述の正規表現を使ってハッシュタグに相当する文字装飾します。

ただし、このときに次のような点に注意して実装する必要があります。

  1. TextView.setTextをするとバックスペースなどが効かないため、直接操作する
  2. 文字列編集中の場合、とくに日本語変換中などはこの処理を行わないように制御する
  3. 文字編集のたびに同じ範囲、同じ文字装飾が行われることを防止する

これらの注意点を実装した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.getEditableTextEditableを取得する必要があります。

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.getEditableTextnullが返却されます。

// (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で日本語入力で変換中であるかを検査せずに文字装飾を行うと、変換前で文字入力が確定してしまいます。
たとえば、ローマ字入力などで「か」という文字を入力しようとする場合、kaのキーを入力する必要がありますが、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.setTextKeepStateTextViewの内容を設定してあげましょう

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ViewModel + LiveData + Databindingでボタンの活性制御

今回の仕様

メールアドレス = 入力されている
パスワード = 6文字以上

この2つが満たされているときだけログインボタンが押せるような、よくあるログイン画面を作りたいと思います。

こんな画面を想定
qiita_ad2019.gif

環境

Android Studio 3.5.2
その他はbuild.gradle参照してください。

必要な知識

  • Android Studioを使える
  • kotlinを書ける
  • activityとfragmentを使ったことがある

リポジトリ

iwahara/button_enabled_sample: for Advent Calendar 2019

はじめに

今回はAndroidXを使うので、プロジェクトを作る際にそれを有効化します。
Fragment+ViewModelを選ぶとはじめから画面表示まで揃ってるのでやりやすいです。
プロジェクトの設定でAndroidXを使う設定を忘れずに。
configure_project.png

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タグにし、その中でdataConstraintLayoutを定義するようにします。
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
    }

実際に動かす

ここまで来たら動くようになっているはずです。
ここまでボイラープレートをなくすことが出来るとは、便利な世の中になりましたね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

OkHttpを使った場合にWebViewのJavascriptInterfaceはどう呼ばれるか軽く調べてみた

AndroidのWebViewでは、addJavascriptInterfaceという、JavaオブジェクトをWebViewに埋め込む機能があります。これにより、WebView内で表示しているWebページに埋め込まれたJavascriptから、当該Javaオブジェクトで定義されたメソッドを呼び出せるようになります。

今回は、WebViewでのHTTP通信にOkHttpを利用した時、WebViewのコールバックメソッドはどう呼ばれるか軽く調べてみました。
読むのがめんどくさい方は最後の「まとめ」だけを読んでいただいてもよいです。

WebViewを使用するActivityの実装

WebViewを使用するActivityのソースコードはこんな感じです。
サンプルコードがお粗末なのはご愛嬌です。

MainActivity.kt
class 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.hs
  1 {-# 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エンドポイントアクセスした際の通信フローをまとめると、以下のような感じになります。

  1. root
  2. "submitFormByJs"
  3. "getJsBySrc"
  4. "doHttpReqFromJsCode.js"
  5. "fromXMLHttpReq"
  6. "onReceivedXHRResAndSetLocationHref"

1. root

HTTPステータス302で"submitFormByJs"へリダイレクト

2. "submitFormByJs"

<form>タグにより、"getJsBySrc"エンドポイントへのPOSTリクエストを送出するhtmlを応答。
WebViewがこのHTMLを解釈するときに、上記POSTリクエストが実行される。

submitFormByJs.html
  1 <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.html
 1 <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.js
  1 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.html
  1 <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.html
  1 <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/onReceivedXHRResAndSetLocationHref

OkHttp通信の箇所の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を作りましょう)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.kt
    override 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.kt
    override 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.16mean_volume=-41.8 dBmax_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? オーディオに関する素朴な疑問あれこれ


  1. 自分みたいな素人だと最大音量が0dBってどういうこと?って思ってしまいますが、今回のdBが意味するところは例えば100dBの音量が出るスピーカーがあるとすると、そこからの減算値のことを指します。0dBであれば減算すること無くそのまま100dBでスピーカーから音が流れるということです。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む