20200911のAndroidに関する記事は8件です。

SPAJAMでAITalkを使ったのでメモ

仕事が忙しかったり、顔にできたできものがすごく腫れて寝込んだりしたので、もっと早く書こうと思ってたけど、SPAJAMが終わって2週間ほどたってしまった^^;

さて、SPAJAMでは協賛企業さんがツールなどを予選や本選の2日間のみ利用できるように開放してくれます。
今回、AITalkを使ったのですが、Androidで利用するサンプルもなかったので、メモとして残しておきます。
来年も使うかもしれないしね。
雑なメモですが、誰かの参考なったら、これ幸いかと。

Kotlinで書いていますが、まだKotlin勉強中なので、変な書き方をしているかもしれません。
ご了承くださいm(_ _)m

AITalkとは?

オフィシャルのAITalkとはを読んでいただくのが一番なんですが、音声合成エンジンですね。
ボカロのしゃべらす版といったところでしょうか?
感情表現もできることが特徴です。
Web APIを呼び出して音声をダウンロードするような感じです。

Androidで使う

AITalkを使うには、HTTP通信を行う必要があります。
SPAJAM用に公開されたURLはHTTPSではなくHTTPでした。
そのため、HTTPで通信できるよう、AndroidManifest.xmlに記述する必要があります・

まずは以下のパーミッションを追加します。

AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

そして、HTTPで通信するため、applicationタグに以下の属性を追加します。

AndroidManifest.xml
<application
・・・中略・・・
android:usesCleartextTraffic="true">

次に、AITalkのWeb APIの呼び出しになります。
AITalkのWebAPIには読み上げテキストや、音声データのフォーマットなどを含める必要があります。
今回は専用のWebサイトがあり、そこでパラメタを作成することができたので、パラメタについては割愛します。
SPAJAMで利用する場合、Web APIの仕様も公開されるのでそれを見てやればよいと思います。
SPAJAMでなくても、契約して利用する場合は仕様が公開されいると思いますので、そちらを参照しましょう。

で、Androidでどのように呼び出したかというと、以下のようなメソッドを作成して読み込みました。
今回は、読み込んだデータを起動時にキャッシュしておいて、後から再生する方法を取りましたので、読み込んだデータをHashMapに登録しています。
また、targetUrlには実際にAITalkのWeb APIのURLを指定します。
ハッカソン用に公開されたURLの為、適当に書いてあります。

やっていることは、単純に、GETのリクエストを作成し、そのレスポンスをバイト配列で読み込んで保持しているだけです。

Android
        private const val targetUrl = "http://host/webapi/xxxxx.php"
        private val talkMap : HashMap<String, ByteArray> = HashMap<String, ByteArray>()
        private var track : AudioTrack? = null

        /**
         * AITalkのデータを読み込んでキャッシュします
         * 以下の出力条件で作成したリクエストデータを引数に指定してください
         * HTTP通信はメインスレッドで呼び出せない為、このメソッドはコルーチンなどの非同期処理で呼び出してください
         */
        fun loadAITalkAudio(name : String, param : String){
            var urlText = "$targetUrl?$param"
            var con : HttpURLConnection? = null
            var baos : ByteArrayOutputStream? = null
            var url = URL(urlText)

            try{
                con = url.openConnection() as HttpURLConnection
                con.connectTimeout = 0
                con.readTimeout = 0
                con.requestMethod = "GET"
                con.useCaches = false
                con.doOutput = false
                con.doInput = true
                con.connect()

                if(HttpURLConnection.HTTP_OK ==  con.responseCode){
                    var bis = BufferedInputStream(con.inputStream)
                    var buff = ByteArray(1024)
                    baos = ByteArrayOutputStream()
                    var len = bis.read(buff)
                    while(len > 0){
                        baos.write(buff, 0, len)
                        len = bis.read(buff)
                    }
                    talkMap[name] = baos.toByteArray()
                }else{
                    var isr = InputStreamReader(con.errorStream)
                    var br = BufferedReader(isr)
                    var line = br.readLine()
                    val sb = StringBuilder()
                    while(line != null){
                        sb.append(line)
                        line = br.readLine()
                    }
                }
            }finally{
                baos?.close()
                con?.disconnect()
            }
        }

実際にこのメソッドを呼び出しているところは、HTTP通信はメインスレッドでできない為、コルーチンを使って呼び出しています。

Android
        runBlocking {
            GlobalScope.launch(Dispatchers.IO) {
                AITalkController.loadAITalkAudio(
                    "orderGuest",
                    "パラメータ"
                )
                AITalkController.loadAITalkAudio(
                    "orderGuest",
                    "パラメータ"
                )
                ・・・中略・・・
                AITalkController.loadAITalkAudio(
                    "orderGuest",
                    "パラメータ"
                )
            }.join()
        }

次に再生ですが、キャッシュしたバイト配列を読み込んで、AudioTrackクラスを利用して再生しています。
AudioTrack自体使い慣れておらず、setBufferSizeInBytes()メソッドに指定しているバッファサイズは自信なしです。
・・・今調べたらちゃんとサイズの計算方法ありましたorz
AudioTrack.getMinBufferSize()メソッドで求められるようです。

writeで46バイトを無視するように読み込んでいるのは、Wave形式の場合、ヘッダ情報が46バイトなので、そのようにしています。
というか、ここのコードはほぼ参考にしたサイトそのまま・・・

ちなみに、AITalkで作成した音声は以下のようなフォーマットになります。

項目
音声形式 wav 8kHz 8bit
WEVEサンプルレート 8000
WEVEビットレート 8
WAVEチャンネル 2
Android
        fun playTrack(name : String){
            if(!talkMap.containsKey(name)){
                return
            }

            if(track != null){
                track?.flush()
                track?.reloadStaticData()
            }

            track = AudioTrack.Builder()
                .setAudioAttributes(
                    AudioAttributes.Builder()
                        .setUsage(AudioAttributes.USAGE_MEDIA)
                        .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                        .build()
                )
                .setAudioFormat(
                    AudioFormat.Builder()
                        .setEncoding(AudioFormat.ENCODING_PCM_8BIT)
                        .setSampleRate(8000)
                        .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
                        .build()

                )
                .setBufferSizeInBytes(8000 * 2)
                .setTransferMode(AudioTrack.MODE_STATIC)
                .build()

            track?.positionNotificationPeriod = 8000 / 10;
            track?.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener{
                override fun onMarkerReached(p0: AudioTrack?) {
                    Log.i("BakeFish", "onMarkerReached")
                }

                override fun onPeriodicNotification(p0: AudioTrack?) {
                    Log.i("BakeFish", "onPeriodicNotification")
                    if(p0?.playState == AudioTrack.PLAYSTATE_STOPPED){
                        Log.i("BakeFish", "onPeriodicNotification End")
                    }
                }
            })

            track?.write( talkMap[name]!!, 46,  talkMap[name]!!.size - 46, AudioTrack.WRITE_BLOCKING)
            track?.play()
        }
    }
}

AudioTrackを理解していなかったのが問題なのですが、再生した音声の終わりにノイズが入ってしまっていました。
が、とりあえずこれで再生できました。
おそらく、パラメタの設定に問題があるんじゃないかと思っています。
この辺、改良したいので、AudioTrackを次回使うときにちゃんと勉強して使いたいと思います。

というわけで、ざっくりでかつ雑ですが、AITalkを使ってみたのでメモとして残しておきました。

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

android 10以降 外部ストレージの画像削除時の対応

概要

他のアプリが保存したメディアファイルを、アプリから削除しようとした際にAndroid 9では削除できましたが、Android 10では削除できませんでした。
Android 10(TargetSDK 29)以降では、アプリが保存した画像等のメディアファイル以外は直接削除できないようになっているようなのでその対応についてまとめてみました。
https://developer.android.com/training/data-storage/files/external-scoped?hl=ja

Android 10でアプリからストレージ内の画像を削除する

Android 10でMediaStoreの画像や動画をcontentResolver.delete()で削除しようとするとRecoverableSecurityExceptionが発生する

sample.kt
fun delete() {
    contentResolver.delete(fileUri, null, null) 
}

実行するとRecoverableSecurityExceptionが発生する。
android.app.RecoverableSecurityException:
sampleApp has no access to content://media/external_primary/images/media/hogehoge

RecoverableSecurityExceptionへの対応

調べてみると、「RecoverableSecurityException」への対応としては、
初回削除時に発生したRecoverableSecurityExceptionをキャッチして、
ユーザーへアプリがメディアファイルへアクセスしても良いか許可を求める必要があるらしい。

sampleActivity.kt
private lateinit var activityResultLauncher: ActivityResultLauncher<IntentSenderRequest>
   ~~
   ~~
    private fun delete() {
        try {
            contentResolver.delete(fileUri, null, null)
        } catch (e: SecurityException) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val recoverableSecurityException =
                    e as? RecoverableSecurityException
                        ?: throw e
                // RecoverableSecurityExceptionからintendSenderを取得
                // intentSenderを利用して、ポップアップを出してファイルアクセス許可をユーザに求める
                activityResultLauncher.launch(
                    IntentSenderRequest.Builder(recoverableSecurityException.userAction.actionIntent.intentSender).build()
            } else {
                throw e
            }
        }
    }
  ~~
  ~~
    // ユーザーが許可した場合、再度削除処理を行う
    activityResultLauncher =
    registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
        if (it.resultCode == Activity.RESULT_OK) {
            delete()
        }
    }

・削除時にRecoverableSecurityExceptionをキャッチ
・RecoverableSecurityExceptionからintentSenderを取得
・取得したintentSenderを実行して、アプリがメディアファイルにアクセスしてもいいか確認する
・ユーザーが許可した場合、アプリに削除権限が与えられるので再度削除処理を行う

上記の流れで削除を実施して、androidのフォトアプリ内を確認すると対象のメディアファイルが消えていた。

しかしながら、androidのストレージ内を確認すると何故かファイルの実体が残っている…。
MediaStoreからは削除できたが、メディアファイルの実体は削除できていなかった。

requestLegacyExternalStorage="true" を使う

ファイルの実体が残り続けてしまう原因を調べてみると、バグなのか仕様なのか分からないが削除したはずのメディアファイルの実体が残ってしまう現象が起きるようだ。

エミュレータでは、上記のコードをテストしたときMediaStoreからアイテムが削除されることを確認しました。 しかし、adb shellで、そのファイルが保存された実際のパスに入ると、ファイルは削除されずに残っています。 この部分がバグなのか、政策による動作かは確かに分かりません。 自分が登録したメディアファイルは、実際のファイルまで削除されていることから見て、実際のファイルも削除されるのが正しい動作のようです。
https://codechacha.com/ja/android-mediastore-remove-media-files/

対応としては、Android 10では「requestLegacyExternalStorage="true"」を使って、対象範囲別ストレージを使わずに、Android 9以前のように削除する方法を使うようです。

Android 10(API レベル 29)をターゲットとしているアプリの場合は、対象範囲別ストレージをオプトアウトし、Android 9 以前の方法を使用してこの操作を行います。
https://developer.android.com/training/data-storage/use-cases?hl=ja

マニフェストに「requestLegacyExternalStorage="true"」を記述

AndroidManifest.xml
  <application
    android:requestLegacyExternalStorage="false">

Android 11での対応

試してみてはいませんが、
Android 11では、MediaStoreにメソッドが追加されるようなのでそれを使ってあげれば良さそう?

createTrashRequest()
ユーザーが指定したメディア ファイルをデバイスのゴミ箱に入れるためのリクエスト。ゴミ箱内のアイテムは、システムが規定する期間後に完全に削除されます。

注: デバイスの OEM にプリインストールされるギャラリー アプリの場合、ダイアログを表示せずにゴミ箱にファイルを移動できます。そのためには、IS_TRASHED を 1 に直接設定します。
createDeleteRequest()
前もってゴミ箱に入れずに、ユーザーが指定したメディア ファイルを完全に削除するためのリクエスト。

これらのメソッドのいずれかを呼び出すと、システムは PendingIntent オブジェクトを作成します。アプリがこのインテントを呼び出すと、指定のメディア ファイルをアプリが更新または削除する同意を求めるダイアログがユーザーに表示されます。
https://developer.android.com/preview/privacy/storage?hl=ja#media-batch-operations

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

Android 11のPackage visibilityへの対応

Android11からはPackageManagerを使ってqueryIntentActivitiesなどで他のアプリがインストールされているか調べるのに制限がかかります。
targetSdkVersionを30にすると、デフォルトでは一部のシステムアプリしか結果が返ってきません、どのようなアプリを調べるのかの宣言が必要になります。

要するに以下の内容です。
https://developer.android.com/preview/privacy/package-visibility?hl=ja

宣言方法は2種類あります。
以下のように、調べたいパッケージ名を指定するか、

AndroidManifest.xml
<manifest package="com.example.game">
    <queries>
        <package android:name="com.example.store" />
        <package android:name="com.example.services" />
    </queries>
    ...
</manifest>

以下のようにIntentFilterで指定する方法です。

AndroidManifest.xml
<manifest package="com.example.game">
    <queries>
        <intent>
            <action android:name="android.intent.action.SEND" />
            <data android:mimeType="image/jpeg" />
        </intent>
    </queries>
    ...
</manifest>

ここで指定するのは、アプリの中でどのようなクエリで調べるかではありません。
ここで指定した条件に合致するアプリが、アプリからのクエリで調べられるようになる、というだけです。

例えば、マニフェストの条件に該当するアプリが A,B で、アプリ内のクエリに合致する端末内アプリが A,C だったとすると、アプリへはAだけが結果として返るようになる。という動作になります。

CustomTabsを使う場合の指定

(Chrome限定ではなく)CustomTabsを使う場合、android.support.customtabs.action.CustomTabsServiceというActionに反応するサービスを持つアプリ、かつ、ブラウザであるパッケージを調べます。
なので、安直に設定するなら以下のようになるでしょう。

AndroidManifest.xml
<queries>
    <intent>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="http"/>
    </intent>
    <intent>
        <action android:name="android.support.customtabs.action.CustomTabsService"/>
    </intent>
</queries>

しかし、android.support.customtabs.action.CustomTabsServiceというActionのクエリだけ書いておけば、ブラウザを調べたときにandroid.support.customtabs.action.CustomTabsServiceに反応し、かつ、ブラウザのアプリだけが返却されるようになるので以下の条件だけ書けばよいことになります。

AndroidManifest.xml
<queries>
    <intent>
        <action android:name="android.support.customtabs.action.CustomTabsService"/>
    </intent>
</queries>

ランチャーから起動可能なアプリの指定

以下のように書けば、ランチャーから起動可能なアプリすべてを調べられるようになります。
特殊なアプリを除けば、ランチャーから起動可能ですので以下の記述でAndroid11未満と同様にほぼすべてのアプリを調べられることになります。

AndroidManifest.xml
<queries>
    <intent>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent>
</queries>

SpeechRecognizerを使う

どうやら、SpeechRecognizerもPackage Visivilityの影響を受けるようです。

AndroidManifest.xml
<queries>
    <intent>
        <action android:name="android.speech.action.RECOGNIZE_SPEECH"/>
    </intent>
</queries>

パッケージを調べるという操作は、そんなにやることはなかろうと思いつつも、CustomTabsぐらいは使っているでしょうし、SDK内部でパッケージを調べている場合も影響を受けるため、targetSdkVersionを30にして動作がおかしい場合はこいつをチェックするようにしましょう。

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

RxJava2CallAdapterFactoryをaddしたRetrofit Clientで返り値Call型が指定されたAPI定義を実行するとどうなるか?

こんな感じでRetorfit Clientを作成しているコードがあるとする。

val retrofitClient = Retrofit.Builder()
    .baseUrl(url)
    .client(okHttpClient)
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .build()

addCallAdapterFactoryRxJava2CallAdapterを追加しているため、このクラインとでは次のようなObservable型を返すようなAPI定義を実行した際にレスポンスをObservableとして扱える。

 interface SmapleAPI {
     @GET("foo/bar")
     fun getSampleResponse(): Observable<Hoge>
 }

この時、次のCall型を返すようにAPI定義しRxJava2CallAdapterを追加したクライアントでAPIリクエストするとどうなるか?

 interface SmapleAPI {
     @GET("foo/bar")
     fun getSampleResponse(): Call<Hoge>
 }

この場合、APIリクエストするとCall型としてレスポンスを受け取れます。

RetrofitがCallAdapterをレスポンスに適用する流れ

addCallAdapterFactoryで渡されたクラス(CallAdapter.Factoryを継承したクラス)はRetrofitクラスのList<CallAdapter.Factory> callAdapterFactories変数に追加される。(callAdapterFactoriesにはデフォルトでDefaultCallAdapterFactoryが追加されている。)

そして、RetrofitクラスのnextCallAdapterメソッドでCallAdapter.FactoryからCallAdapterを得てそれがレスポンスに適用される。(HttpServiceMethodクラスのadaptとそれをoverrideした各クラスを参照)

この処理は次のnextCallAdapterコードで行っている。

int start = callAdapterFactories.indexOf(skipPast) + 1;
for (int i = start, count = callAdapterFactories.size(); i < count; i++) {
    CallAdapter<?, ?> adapter = callAdapterFactories.get(i).get(returnType, annotations, this);
    if (adapter != null) {
        return adapter;
    }
}

ここで、CallAdapter.Factoryのgetメソッドの戻り値がnullとなる場合、nextCallAdapterメソッドはadapterを返さないのでレスポンスに適用されない。

そして、RxJava2CallAdapterFactoryのgetメソッドでは次のような判定を行い、rawTypeObservableFlowableSingleMaybe型でなければnullを返している。

そのため、nextCallAdapterメソッドでもRxJavaCallAddapterを返さず、レスポンスにRxJavaCallAddapterが適用されることは無い。

boolean isFlowable = rawType == Flowable.class;
boolean isSingle = rawType == Single.class;
boolean isMaybe = rawType == Maybe.class;
if (rawType != Observable.class && !isFlowable && !isSingle && !isMaybe) {
    return null;
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Android, Kotlin: テストコードを書く

  • Minimum SDK: API 23
  • Android Studio 4.0.1
  • Kotlin 1.4.0

以前、Androidのテストを、UnitTestにて実施しようとしました。
AndroidでのUnitTestざっくり入門

ですが、Realmが絡んだテストが実施できず、またRealmの実行を含んだオブジェクトのモック化もうまくいきませんでした。(元の処理が実行されてしまいました。)

なので、方針を変更しInstrumentedTest(androidTest)の方でテストケースを実施するようにします。

やりたいことは次のこととなります。

  • 表示の確認
  • 入力値の確認
  • データベースに格納されているかの確認
  • 画面遷移:次のActivityにIntentが渡されているかの確認

準備

Realmの準備はこちらを参照:Android,KotlinでRealm

一番上は、プロジェクトを作成した時に既に入っていました。

bundle.gradle(app)
dependencies {
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    androidTestImplementation 'androidx.test:runner:1.1.0'
    androidTestImplementation 'androidx.test:rules:1.1.0'
    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.0'
}

テスト対象のアプリ

Viewは省略しますが、次のようなアプリをテスト対象とします。

  • textView: 初期表示はHello World!
  • editText: 入力フォーム
  • button1:textViewの内容書き換え
  • button2: editTextの内容をデータベースに保存
  • button3: 次の画面(SecondActivity)に、editTextの内容を渡して遷移
  • SecondActivity: 受け取ったテキストをtextViewに表示します。
MainActivity
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<Button>(R.id.button1).setOnClickListener {
            findViewById<TextView>(R.id.textView).text = "Click"
        }

        findViewById<Button>(R.id.button2).setOnClickListener {
            val text = findViewById<EditText>(R.id.editText).text.toString()
            Realm.getDefaultInstance().executeTransaction {
                it.copyToRealm(Data(UUID.randomUUID().toString(), text))
            }
        }

        findViewById<Button>(R.id.button3).setOnClickListener {
            val text = findViewById<EditText>(R.id.editText).text.toString()
            val intent = Intent(this, SecondActivity::class.java)
            intent.putExtra("text", text)
            startActivity(intent)
        }
    }
}
Data
open class Data(
    @PrimaryKey var id : String = "",
    var value: String = ""
) : RealmObject()

テストケース:前処理

テストケースの前処理でRealmをメモリで動かす設定をしておきます。
メモリで動かす設定にしていてもファイルは作られるみたいで、名前を毎回変更しないとエラーになってしまいます。

また、テスト対象のActivityをIntentsTestRuleで定義します。

@RunWith(AndroidJUnit4::class)
class SampleInstTest {
    @Before
    fun init() {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        Realm.init(context)
        val builder = RealmConfiguration.Builder()
        builder.inMemory()
        builder.name(UUID.randomUUID().toString())
        Realm.setDefaultConfiguration(builder.build())
    }

    @get:Rule
    val intentRule = IntentsTestRule(MainActivity::class.java)
}

テストケース:表示の確認

初期表示の確認と、ボタンクリック時に表示が変更されているかの確認です。

    @Test
    fun changeTextView() {
        // 初期表示
        onView(withId(R.id.textView)).check(matches(withText("Hello World!")))

        // ボタン1クリック:表示切り替え
        onView(withId(R.id.button1)).perform(ViewActions.click())
        onView(withId(R.id.textView)).check(matches(withText("Click")))
    }

テストケース:入力値の確認と、データベースの確認

Realmのオブジェクトは、データ登録後に取得しないと反映されていませんでした。

    @Test
    fun saveData() {
        // テキスト入力
        onView(withId(R.id.editText)).perform(ViewActions.typeText("InputTest"))

        // ボタンク2リック:データベースに保存
        onView(withId(R.id.button2)).perform(ViewActions.click())

        // 入力したデータが一つ入っていることを確認
        var realm = Realm.getDefaultInstance()
        Assert.assertEquals(1, realm.where(Data::class.java).count())
        val data = realm.where(Data::class.java).findFirst()
        Assert.assertEquals("InputTest", data?.value ?: "")
        realm.close()

        // もう一回クリックで、データが増えていることを確認
        onView(withId(R.id.button2)).perform(ViewActions.click())
        realm = Realm.getDefaultInstance()
        Assert.assertEquals(2, realm.where(Data::class.java).count())
        realm.close()
    }

テストケース:画面遷移

    @Test
    fun callAcivity() {
        // テキスト入力・ボタン3クリック
        onView(withId(R.id.editText)).perform(ViewActions.typeText("NextActivity"))
        onView(withId(R.id.button3)).perform(ViewActions.click())

        // 遷移先のActivityと、Intentでデータが渡されているかを確認
        intended(allOf(
            hasComponent(hasShortClassName(".SecondActivity")),
            hasExtra("text", "NextActivity"))
        )
    }

テストケース:遷移先

遷移先の確認は、テスト対象のActivityが変わるため別クラスで行います。
こちらのテスト対象は、ActivityTestRuleで指定します。

SecondInstTest
@RunWith(AndroidJUnit4::class)
class SecondInstTest {
    @get:Rule
    val activityRule = ActivityTestRule(SecondActivity::class.java)

    @Test
    fun startNextActivity() {
        // Intentを渡してActivity起動
        val intent = Intent()
        intent.putExtra("text", "PassedText")
        activityRule.launchActivity(intent)

        // 表示確認
        onView(withId(R.id.textView)).check(matches(withText("PassedText")))
    }
}

TestRuleについて

  • ActivityTestRule: 基本のルール
  • ActivityScenarioRule: Activityの起動、終了など自動でやってくれます。基本はこれを使用すればいいと思います。今回は使ってませんが。
  • IntentsTestRule: 画面遷移を伴うテストの時に使用します。

その他

またの機会に、ListViewなどのテストも追記していきます。

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

2. 【Android/Kotlin】ボタン追加

はじめに

DreamHanksのMOONです。

前回はAndroid開発ツールのAndroid Studioをインストールしました。
https://qiita.com/DreamHanks/items/ad6e8002044dcca26bf7

今回はアプリの画面にボタンを追加し、そのボタンにイベント機能を追加してみます。

Viewの概要

2.PNG
View オブジェクトは、通常は「ウィジェット」と呼ばれ、Button や TextView などの多数のサブクラスの 1 つです。ViewGroup オブジェクトは、通常は「レイアウト」と呼ばれ、LinearLayout や ConstraintLayout などの異なるレイアウト構造を提供する多数のタイプの 1 つです。

詳細な概要は、
https://developer.android.com/guide/topics/ui
このリンクで確認することができます。

ボタン追加とイベント設定

まず、ボタンヴューをレイアウトに追加します。
1.PNG

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>                                  
<LinearLayout 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"                                    
    android:orientation="vertical"                                  
    tools:context=".MainActivity"                                   
    android:gravity="center">                                   

    <TextView                                   
        android:layout_width="wrap_content"                                 
        android:layout_height="wrap_content"                                    
        android:id="@+id/main_tv"                                   
        android:text="テキストです。" />                                 

    <Button                                 
        android:layout_width="wrap_content"                                 
        android:layout_height="wrap_content"                                    
        android:id="@+id/change_btn"                                    
        android:text="ボタン"/>                                  

</LinearLayout> 

レイアウトはいろんなレイアウト種類がありますけど、ヴューを配置する時に一番使いやすい「LinearLayout」を使用してヴューを追加します。
次にボタンを追加します。

MainActivity.kt
package com.example.practiceapplication                                                 

import androidx.appcompat.app.AppCompatActivity                                                 
import android.os.Bundle                                                    
import android.widget.Button                                                    
import android.widget.TextView                                                  

class MainActivity : AppCompatActivity() {                                                  
    override fun onCreate(savedInstanceState: Bundle?) {                                                    
        super.onCreate(savedInstanceState)                                                  
        setContentView(R.layout.activity_main)                                                  

        val main_tv = findViewById<TextView>(R.id.main_tv)        //画面のテキストヴュー                                                  
        val change_btn = findViewById<Button>(R.id.change_btn)    //画面のボタン

        //ボタンクリックイベントリスナー設定                                               
        change_btn.setOnClickListener {                                                 
            main_tv.text = "クリックされました‼"                                                 
        }                                                   
    }                                                   
}       

1.「findViewById」メソッドでテキストヴューとボタンオブジェクトを取得します。
2.クリックされた場合、テキストの内容を変更させるクリックリスナーをボタンに設定します。

アプリ起動

このアプリを実行するためには仮想デバイスやアンドロイドスマホが必要です。

仮想デバイスのエミュレーターで実行する場合は、
https://developer.android.com/studio/run/emulator

ハードウェアデバイス上で実行する場合は、
https://developer.android.com/studio/run/device

各環境に合わせて設定後、アプリを実行
今回はハードウェアデバイス上で実行します。

・ボタンをクリックする前

・ボタンをクリックする後

終わりに

今回はボタンを追加し、クリックイベント設定までしてみました。
次回は画面遷移について見ていきたいと思います。

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

Android(ネイティブ)で開発とflutterで開発の比較

はじめに

モバイルエンジニア歴2年の若手です。
この2年でAndroid開発とflutterでの開発の両方に携わることが出来たので、それを通して感じたことを書いていきます。
今回は比較にあたって、ポモドーロタイマーのアプリを作成いたしました。

仕様

  • 初回起動時に25分(作業)のタイマーがセットされている
  • 画面には開始ボタンと停止ボタンがある
  • 以下のサイクルでタイマーが使用できる
  1. スタートボタンを押すことでタイマーが開始される
  2. タイマーの残りの時間が0秒になると5分(休憩)のタイマーがセットされる
  3. スタートボタンを押すと休憩時間が開始される
  4. タイマーの残りの時間が0秒になると25分(作業)のタイマーがセットされる
  5. 項番1に戻る
  • タイマー作動中に停止ボタンを押すと25分の作業時間にリセットされる

アプリ画面

  • Androidで開発した画面
  • flutterで開発した画面

ソースコード

Android

GitHub

  • MainActivity.kt
package com.example.androidpomodoro

import android.os.Bundle
import android.os.Handler
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.android.synthetic.main.activity_main.*
import java.util.*

class MainActivity : AppCompatActivity() {
    private val brakeTime: Int = 300 // 5分
    private val workTime: Int = 1500 // 25分
    private var current: Int = workTime
    private var isWorkTime: Boolean = false
    private var isStart: Boolean = true

    private var timer: Timer? = null
    private val handler = Handler()


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        var textTitle = findViewById<TextView>(R.id.text_title)
        var textTimer = findViewById<TextView>(R.id.text_timer)
        var startButton = findViewById<FloatingActionButton>(R.id.start_button)
        var stopButton = findViewById<FloatingActionButton>(R.id.stop_button)
        val listener = StartStopListener()

        textTitle.setText(R.string.work_time)
        textTimer.text = formatTime()
        startButton.setOnClickListener(listener)
        stopButton.setOnClickListener(listener)
    }

    private inner class StartStopListener : View.OnClickListener {
        override fun onClick(view: View?) {
            when (view!!.id) {
                R.id.start_button -> {
                    if (isStart) {
                        isStart = false
                        startTimer()
                    } else {
                        null
                    }
                }
                R.id.stop_button -> {
                    if (!isStart) {
                        resetTimer()
                    } else {
                        null
                    }
                }
            }
        }
    }


    private fun startTimer() {
        timer = Timer()
        timer!!.schedule(object : TimerTask() {
            override fun run() {
                handler.post {
                    if (current == 0) {
                        isStart = true
                        timer!!.cancel()
                        if (isWorkTime) {
                            current = workTime
                            isWorkTime = false
                            text_title.setText(R.string.work_time)
                            text_timer.text = formatTime()
                        } else {
                            current = brakeTime
                            isWorkTime = true
                            text_title.setText(R.string.brake_time)
                            text_timer.text = formatTime()
                        }
                    } else {
                        current--
                        text_timer.text = formatTime()
                    }
                }
            }
        }, 0, 1000)
    }

    private fun formatTime(): String {
        val minutes = (current / 60).toString().padStart(2, '0')
        val seconds = (current % 60).toString().padStart(2, '0')
        return "$minutes:$seconds"
    }

    private fun resetTimer() {
        isStart = true
        timer!!.cancel()
        current = workTime
        isWorkTime = false
        text_title.setText(R.string.work_time)
        text_timer.text = formatTime()
    }

}
  • activity_main.xml
<?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"
    tools:context=".MainActivity">

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/stop_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:clickable="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.676"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.397"
        app:srcCompat="@mipmap/stop" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/start_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:clickable="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.295"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.397"
        app:srcCompat="@mipmap/play" />

    <TextView
        android:id="@+id/text_timer"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="50sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.167" />

    <TextView
        android:id="@+id/text_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="30sp"
        app:layout_constraintBottom_toTopOf="@+id/text_timer"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
  • strings.xml
<resources>
    <string name="app_name">androidPomodoro</string>
    <string name="work_time">ワークタイム</string>
    <string name="brake_time">ブレイクタイム</string>
</resources>

flutter

GitHub

  • main.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Pomodoro',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _brakeTime = 300; // 5分
  int _workTime = 1500; // 25分
  int _current = 1500;
  bool _isWorkTime = false;
  bool _isStart = true;
  String _titleText = "ワークタイム";
  Timer _timer;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: SafeArea(
        child: Container(
          child: Column(
            children: [
              // タイトル
              Container(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    SizedBox(
                      height: 20,
                    ),
                    Text(
                      _titleText,
                      style: TextStyle(fontSize: 30),
                    ),
                  ],
                ),
              ),
              SizedBox(
                height: 50,
              ),

              // 時間表示
              Container(
                child: Text(
                  formatTime(),
                  style: TextStyle(fontSize: 50),
                ),
              ),
              SizedBox(
                height: 50,
              ),

              // ボタン
              Container(
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    FloatingActionButton(
                      child: Icon(Icons.play_arrow),
                      onPressed: !_isStart
                          ? null
                          : () {
                              _isStart = false;
                              startTimer();
                            },
                    ),
                    SizedBox(width: 50),
                    FloatingActionButton(
                      child: Icon(Icons.stop),
                      onPressed: _isStart
                          ? null
                          : () {
                              setState(() {
                                resetTimer();
                              });
                            },
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  void startTimer() {
    _timer = Timer.periodic(const Duration(seconds: 1), onTimer);
  }

  void onTimer(Timer timer) {
    if (_current == 0) {
      setState(() {
        _isStart = true;
        _timer.cancel();
        if (_isWorkTime) {
          _current = _workTime;
          _isWorkTime = false;
          _titleText = "ワークタイム";
        } else {
          _current = _brakeTime;
          _isWorkTime = true;
          _titleText = "ブレイクタイム";
        }
      });
    } else {
      setState(() {
        _current--;
      });
    }
  }

  String formatTime() {
    final minutes = (_current / 60).floor().toString().padLeft(2, '0');
    final seconds = (_current % 60).floor().toString().padLeft(2, '0');
    return "$minutes:$seconds";
  }

  void resetTimer() {
    setState(() {
      _isStart = true;
      _timer.cancel();
      _current = _workTime;
      _isWorkTime = false;
      _titleText = "ワークタイム";
    });
  }
}

flutterから見たAndroid(ネイティブ)開発

良い所

  • UIでレイアウトを組める
  • レイアウトを組んでる途中にプレビューが見れる
  • ネイティブの機能を全て使うことが出来る

悪い所

  • 画面の向きを変えたら画面が再生成されてしまうなど、とにかくライフサイクルの管理が大変
  • レイアウトのファイルとActivityのファイルが完全に分かれているので、textやbuttonなどの宣言が面倒

Android(ネイティブ)開発から見たflutterでの開発

良い所

  • hot reloadが早く、素早く動作確認が可能に
  • widgetが豊富
  • ライフサイクルの管理をflutter側が管理してくれている
  • レイアウトを別ファイルで管理する必要がない
  • MaterialDesignに沿ったデザインが作り易い
    • Themeを変更すれば全ての画面で変更される為、カスタマイズも簡単

悪い所

  • ネイティブの機能を全て使うことが出来ない
    • 主要な機能はflutterで使うためのpluginも用意されている
  • レイアウトを組んでいる最中にプレビューが見れない
    • hot reloadが爆速の為、そこまで気にならない

最後に

自分はこの比較を通してflutterがより好きになりました。
学習コストもAndroidの方が圧倒的に高いなと感じました。
今回は簡単なアプリでの比較だった為、flutterの方が優勢になってしまった可能性はありますが、今後flutterが成長して、ネイティブと変わりないくらいになってくれたら良いなと思いました。

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

【React Native】起動時にドロワーが一瞬表示されてしまう

はじめに

React Navigation v2→v5 に一新する機会があり、
その際にAndroidでクリティカルなバグが発生したので、解決策を備忘録として残しておきます。

起動時にドロワーが一瞬表示される

呼んでもないのに一瞬ドロワーがひょっこりしてしまいます。

※わかりやすいようにドロワーの色は黒にしています。
drawer.gif

解決策

ドロワーのwidth値が影響している模様。
初回renderフラグを用いて、widthを制御することで現れなくなります。

※下記コードは一部になります。

initialRender.js
import { useState } from 'react';

export const initialRender = () => {
  const [isInitialRender, setIsInitialRender] = useState(false);

  if (!isInitialRender) {
    setTimeout(() => setIsInitialRender(true), 1);
    return true;
  }
  return false;
};
navigation.js
import { initialRender } from './initialRender'
const Drawer = createDrawerNavigator();

function MyDrawer() {
  return (
    <Drawer.Navigator
      initialRouteName='Tab'
      drawerStyle={{ width: initialRender() ? 0  : 300 }} // 初回はwidthを0にする。
    >
      <Drawer.Screen name="Tab" component={HomeTabs}/>
    </Drawer.Navigator>
  );
}

おわりに

他に良い解決方法がありましたら、教えていただけると幸甚です。

参考

https://github.com/react-navigation/react-navigation/issues/7561

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