20191221のAndroidに関する記事は18件です。

android初心者がLottieを使って簡単にアニメーションを追加できた話

はじめに

この記事はFUN part2 Advent Calendar 2019の22日目の記事です。
前日の記事はSCPで始めるAllenNLP ~SCPオブジェクトクラス分類~でした!
また、同日にTomokaさんが記事をあげてくださっていると思うのでそちらも是非

本題に入る前に

今回の記事は12月に開かれたハッカソンでLottieの存在を知った僕がLottieを知らない人向けにこんなライブラリがあったよといった紹介をする記事であり、詳細な使い方やAndroid studio以外での使い方などについては他のページをご参照ください。

Lottieとは

Easily add high-quality animation to any native app.

Lottie is an iOS, Android, and React Native >library that renders After Effects >animations in real time, allowing apps to >use animations as easily as they use static >images.
出典:Lottie - Airbnb Design

つまるところどんなプラットフォームのアプリでも簡単に高品質なアニメーションが使えるよ〜っていうライブラリですね(意訳)

このライブラリはAfter Effectsで作成されたアニメーションをjsonファイルに変換することで手軽に自分のアプリにアニメーションを追加することができるという優れものです。
これを追加するだけでアプリの見栄えが一気に良くなり、完成度が上がった感じがするからすごい。感動。頭が上がらない。

ちなみに、Lottieの詳細な概念などを理解するのにはこちらのスライド、動画がわかりやすいと思いますのでこちらも是非!
https://droidkaigi.jp/2019/timetable/70876/

使い方

手順1

まずはLottieFilesやLottieプロジェクトのサンプルなどから自分の意図に沿ったアニメーションを探してきてjsonファイルでダウンロードします。
ちなみに、この時落としてきたファイル名にハイフンや数字が入っているとAndroid studioのビルドが通らない恐れがあるのでいい感じに変更してください

手順2

次にAndroid studioにLottieライブラリをインポートする、もしくは下記コードをbuild.gradleのdependencies内に記述します。

build.gradle
implementation 'com.airbnb.android:lottie:$lottieVersion' 

また、手順1でダウンロードしたjsonファイルをres下にrawディレクトリを作るなどして配置します

スクリーンショット 2019-12-21 23.04.24.png

手順3

最後に追加したい画面のレイアウトxmlに

sample.xml
<com.airbnb.lottie.LottieAnimationView
        android:layout_width="100dp"
        android:layout_height="100dp"
        app:lottie_autoPlay="true"
        app:lottie_loop="true"
        app:lottie_rawRes="@raw/snowing"
        />

のような記述を行うだけでアニメーションを再生させることができます
lottie_gif.gif
きっとかんたんですね!!!

最後に

lottie_loopをfalseにすると一度だけのアニメーション表示になったり、.kt内にコードを記述してアニメーションを表示させるといった方法や、ボタンが押された時にアニメーションが表示されるようにするなどと色々と設定することができるのでandroidエキスパートの方が使うとさらに凝ったものにできる非常に便利なライブラリだと思います!
当初Lottieという存在を知ってから理解するのに1時間以上かけていたため、簡単に使えるとか言っている記事を見てはよくわからない顔をしていたのですが理解してみればたしかに簡単でした。
明日はchikuwa_ITさんと穂乃夏さんです。明日も楽しみにしていたいと思います。

参考

公式HP
https://airbnb.design/lottie/
https://lottiefiles.com/
Lottieを理解するのにありがたいスライド、動画
https://droidkaigi.jp/2019/timetable/70876/
使いこなそうとにらめっこした記事
https://qiita.com/ikemura23/items/8c5f87f98cb7fc3ceb27
https://qiita.com/wiroha/items/bc361218629ca03fbd19
https://dev.classmethod.jp/smartphone/android/lottie-getting-started/
https://motida-japan.hatenablog.com/entry/2017/02/19/145537
https://droidkaigi.jp/2019/timetable/69036/

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

GitHub Actions で Android アプリをビルドして apk ファイルをアップロードする

これは ゆめみ その2 Advent Calendar 2019 の19日目の投稿です。

今回はタイトルどおり、GitHub Actions で Android アプリのビルドを実行し、apk ファイルをアップロードするワークフローを紹介します!

apk ファイルのアップロード先について

実際に仕事のプロジェクトでビルドを自動化する場合は、Firebase App Distribution なり DeployGate へ apk ファイルをアップロードした方が便利でしょう。

ただ今回は、それらのアカウントを用意するまでもないような小さなプロジェクトや個人プロジェクトを想定して、GitHub Actions の Artifacts へ apk ファイルをアップロードすることにします。(この方法であれば別途 Firebase 等のアカウントを用意する必要はありません。)

ワークフロー

GitHub Actions の Ubuntu の環境は、Android アプリをビルドする為の SDK がはじめから導入されているので、とくに何もせずそのままビルドすることが出来ます。今回はビルドの前に、Android Lint で致命的なエラーが無いか、また Unit テストが通るかもチェックすることとします。このワークフローは次のようになります。

name: Android CI

on:
  push:
    branches:
      - master # masterブランチへのマージを契機にビルドする

jobs:
  build:

    name: Build on merge # GitHub上で識別しやすいのでジョブの名前をつけておく

    runs-on: ubuntu-latest

    steps:
      - name: Check out
        uses: actions/checkout@v1
      - name: Set up JDK
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: Run Android Lint
        run: ./gradlew lintDebug
      - name: Run Unit Test
        run: ./gradlew testDebug
      - name: Build with Gradle
        run: ./gradlew assembleDebug
      - name: Upload apk file
        uses: actions/upload-artifact@v1
        with:
          name: apk
          path: app/build/outputs/apk/debug/app-debug.apk

上記のワークフローのとおり、Artifacts へのアップロードは https://github.com/actions/upload-artifact を利用しています。

ビルドが成功すると、GitHub Actions のジョブのページ に apk ファイルがアップロードされます。

スクリーンショット 2019-12-21 21.17.51.png

おわりに

いかがだったでしょうか。ビルドの成否を Slack へ等へ通知するともっと便利かもしれません。GitHub Actions については、次のような投稿もしていますので、よろしければご覧ください!

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

ARCoreで変面ARを作ってみる

はじめに

この記事は、ZOZOテクノロジーズ #4AdventCalendar2019の記事です。
昨日は@hirotakanさんの「アニメーションを止めるな!」の記事でした。

ZOZOテクノロジーズでは、他にもAdventCalenderを書いている方がいらっしゃるので、よかったらみていってください!

はじめまして、普段はAndroidエンジニアをやっている@zukkeyです。

今日は、Android Studioで、ARCoreのAugmented Facesを利用した変面ARの作り方について書こうと思います。
(当初予定していた記事は内容がネット上に多く上がっており被りそうだったので、急遽変更しました)

  • 想定している読者
    • 変面ARについて興味がある方
    • Augmented Facesをこれから触る方
    • 顔拡張ARを導入から作成まで知りたい方

最後の方にサンプルを載せましたので、しばしお付き合いください。

まずは、本題に入る前にARCoreとは、Augmented Facesとは、変面とは何かについて軽く触れていきます。

ARCoreとは

ARCoreとは、Googleが提供しているSDKです。AR機能に必須のモーショントラッキングや環境、光の加減などを全てサポートしてくれており、SDKを利用することで既存のアプリケーションにAR機能を搭載したり、新しいARアプリケーションを構築することができます。

Augmented Facesとは

顔を検出し、検出された顔の異なる領域を識別し、アセットなどを識別された領域に一致するようにオーバーレイしてくれるARCoreの機能の一つです。

変面とは

一瞬でお面が変わる中国の伝統芸能です。呼び方は厳密には違うようです。
参考:川劇 | wikipedia

今回は、この変面をARで実現したいというモチベーションで作った変面ARの作り方について紹介していきます。
ここからは公式ドキュメントにも書かれている内容であるので、変面のロジックだけ知りたい方は、実装1フレーム毎に顔を検出したか確認して、顔にテクスチャをレンダリングするあたりから参照するのをおすすめします。

まずは、実装前準備から見ていきましょう。

実装前準備

はじめに、ARCoreを導入する必要があるので、build.gradle(app)に次のように追加してください。
コード中の...は省略を意味します。以後同様です。

build.gradle(app)
...
apply plugin: 'com.google.ar.sceneform.plugin'
...
dependencies {
    ...
    // Sceneform
    implementation "com.google.ar.sceneform.ux:sceneform-ux:1.14.0"
    ...
}

Argumented Facesのはscenformを利用するので依存関係を追加します。2019年12月現在、最新バージョンは1.14.0です。
SceneformAssetsを利用するのにプラグインの導入が必要ですので、一番上にapply plugin: 'com.google.ar.sceneform.plugin'を記載します。

Sceneformとは、OpenGLを習得せずともARアプリやAR機能を持つアプリでリアルな3Dシーンを簡単にレンダリングすることができるARCoreのライブラリの一つです。
参考:Sceneform | ARCore

また、SceneformライブラリはJava 8を利用しているため、minSDKが26より下の場合は下記のcompaileOptionsを追加してください。

build.gradle(app)
android {
    ...
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    ...
}

次に、3Dモデルを取り込むためにプラグインを有効にする必要があります。

Android Studioで、Preferences | PluginsよりMarketplaceタブにてSceneformと検索しますと次のような画面が出てきます。

スクリーンショット 2019-12-21 17.43.41.png

Google Sceneform Toolsをクリックすると、右上にinstallのボタンが出ていると思いますので、そちらをクリックしてください。
インストールが終わると次のような画面になります。

スクリーンショット 2019-12-21 17.46.14.png

このあと再起動するとpluginを利用できるようになっていると思います。

pluginを有効化したら、次は3Dモデルをプロジェクトに読み込みます。

最初に、3Dモデルやテクスチャなどを置くようにapp配下にsampledataディレクトリを作成します。

次に、sampledataディレクトリ上で右クリックをして、New > Sceneform Assetを選択してください。
スクリーンショット 2019-12-21 18.06.06.png

選択するとImport Wizardが出てくるので、こちらでSource Asset Pathにて読み込みたい3Dモデルを指定します。
読み込んだ3DモデルをpluginがAndroid Studio側で利用できるように変換し、sfaファイルとsfbファイルを生成してくれるのでそれぞれの出力先も指定します。
sfaファイルの出力先は先ほど作成したsampledataにし、sfbファイルの出力先はresrawディレクトリを作成しこちらを指定します。sfbファイルはコードから指定するのでres配下であることが良いです。
実際は次のような画面になります。

スクリーンショット 2019-12-21 18.12.11.png

顔用の3Dモデルを用意するのが面倒な場合は、Googleが公式でSceneform Assets | GitHubより、
白面の3Dモデルを用意しているためそちらを利用してみてください。
今回のサンプルでもこちらを利用しています。

最後に、顔に表示する画像を複数用意します。
Sceneform Assets | GitHubより、canonical_face_texture.psdをコピーしてきて、GIMPかPhotoshopで加工します。GIMPなら無料でpsdファイルを開いて編集できるのでおすすめです。
自分が表示したい顔の画像を作成してExportし、Android Studioに戻ってきてres > drawableフォルダに作成した画像を置きます。

これで実装前の準備は終わりです。次は実装を見ていきましょう。

実装

実装の手順としては、次の通りになります

  • AugmentedFace用に設定したカスタムArFragmentを用意する
  • AR機能が利用できる機種か確認する
  • 顔の3Dモデルをレンダリングする
  • テクスチャをビルドしてリストで持つ
  • 1フレーム毎に顔を検出したか確認して、顔にテクスチャをレンダリングする
  • 顔の検出状態によってテクスチャを張り替える

まずはじめに、AugmentedFace用に設定したカスタムArFragmentを用意する部分から見ていきましょう。

AugmentedFace用に設定したカスタムArFragmentを用意する

ARCoreを利用してARを実装するには、ArFragmentを用意する必要があります。
しかし、デフォルトで顔拡張AR用に設定されていないためArFragmentを顔拡張AR用に設定したカスタムArFragmentを用意する必要があります。

FaceArFragmentを次のように用意します。

FaceArFragment.kt
class FaceArFragment: ArFragment() {
    override fun getSessionConfiguration(session: Session?): Config {
        return Config(session).apply {
            augmentedFaceMode = Config.AugmentedFaceMode.MESH3D
        }
    }

    override fun getSessionFeatures(): MutableSet<Feature> {
        return EnumSet.of<Feature>(Feature.FRONT_CAMERA)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val frameLayout = super.onCreateView(inflater, container, savedInstanceState) as FrameLayout?
        planeDiscoveryController.hide()
        planeDiscoveryController.setInstructionView(null)
        return frameLayout
    }
}

まず、3つの関数をoverrideします。
getSessionConfigurationでは、Arシーンを構築する設定で顔拡張用にMESH3Dモードに変更を行います。

getSessionFeaturesではArgumentedFaceを行うのにフロントカメラを利用したいため、変更を行っています。

onCreateViewでは、デフォルトで入っているユーザーに使い方を指示するチュートリアルを隠しています。

AR機能が利用できる機種か確認する

ARCoreにはARが利用できる機種かどうかを判定するメソッドがArCoreApkクラス内にあります。
コードでは次のようにしてARが利用できる機種か確認することができます。

MainActivity.kt
...
private fun checkArSupport() {
    val availability = ArCoreApk.getInstance().checkAvailability(this)
    if (availability.isSupported) {
        Toast.makeText(this, "AR機能が利用できます", Toast.LENGTH_SHORT).show()
    } else {
        Toast.makeText(this, "AR機能を利用することができません", Toast.LENGTH_SHORT).show()
        finish()
        return
    }
}
...

availability.isSupportedで判定できるのでサポートされている場合とそうでない場合の処理を記載します。

顔の3Dモデルをレンダリングする

顔の領域に取り付ける3Dモデルをレンダリングします。
コードで書くと次のとおりです。

MainActivity.kt
...
private fun setUpModel() {
    ModelRenderable.builder()
        .setSource(this, R.raw.face_sample)
        .build()
        .thenAccept { renderable -> faceRegionsRenderable = renderable }
    ...
}
...

レンダリングが正常に終わると、thenAcceptにてレンダリングされたモデルを取得できます。

テクスチャをビルドしてリストで持つ

先に取得したModelに対して画像を貼り付けるためテクスチャをビルドします。
今回は、変面を実装するために、複数のテクスチャをビルドしてリストで持つようにしています。
コードで書くと次のとおりです。

MainActivity.kt
class MainActivity: AppCompatActivity() {
    ...
    private val textureList = mutableListOf<Texture>()
    ...
    private fun setUpTexture() {
        val textureIds = listOf(
            ...
        )
        textureIds.forEach {
            Texture.builder()
                .setSource(this, it)
                .build()
                .thenAccept { texture ->
                    textureList.add(texture)
                }
        }
    }
    ...
}

テクスチャ用に前に用意しておいたdrawableのIdを取得して、thenAcceptにてコードで利用できるTextureを取得できます。

1フレーム毎に顔を検出したか確認して、顔に3Dモデルとテクスチャをレンダリングする

3Dモデルとテクスチャのレンダリングを終えたので、次はカメラを通して1フレーム毎に顔を検出した際に、顔に3Dモデルとテクスチャをレンダリングします。
コードで書くと次のとおりです。

MainActivity.kt
...
private fun setUpFrame() {
    val arFragment = supportFragmentManager.findFragmentById(R.id.ar_fragment) as? ArFragment
    val sceneView = arFragment?.arSceneView ?: throw IllegalArgumentException()
    ....
    sceneView.scene.addOnUpdateListener {
        if (faceRegionsRenderable == null || textureList.isNullOrEmpty()) {
            return@addOnUpdateListener
        }

        val faceList = sceneView.session?.getAllTrackables(AugmentedFace::class.java)
            ?: return@addOnUpdateListener

        for (face in faceList) {
            if (!faceNodeMap.containsKey(face)) {
                val faceNode = AugmentedFaceNode(face)
                faceNode.setParent(sceneView.scene)
                faceNode.faceRegionsRenderable = faceRegionsRenderable
                faceNode.faceMeshTexture = textureList.first()
                faceNodeMap[face] = faceNode
            }
        }
        ....
    }
}
...

addOnUpdateListenerは、1フレーム毎に呼ばれ、getAllTrackablesで検出された顔(AugmentedFace)のリストを取得し、検出された顔がまだ無ければ、顔に対して3Dモデルとテクスチャを取り付けAR空間にセットします。
これで先に用意した顔の3Dモデルとテクスチャを反映することが可能になります。

顔の検出状態によってテクスチャを張り替える

変面をするにあたって、顔の検出の状態によってテクスチャを切り替える必要があります。
今回の実装では、リストで持っているテクスチャを順番に表示するように実装しています。
実際のコードは次のとおりです。

MainActivity.kt
...
private fun setUpFrame() {
    val arFragment = supportFragmentManager.findFragmentById(R.id.ar_fragment) as? ArFragment
    val sceneView = arFragment?.arSceneView ?: throw IllegalArgumentException()
    ....
    sceneView.scene.addOnUpdateListener {
        ....
        val iterator = faceNodeMap.entries.iterator()
            while (iterator.hasNext()) {
                val entry = iterator.next()
                val face = entry.key
                if (face.trackingState == TrackingState.PAUSED && textureList.lastIndex != 1) {
                    val faceNode = entry.value
                    faceNode.setParent(null)
                    textureList.removeAt(0)
                    iterator.remove()
                }
            }
        ....
    }
}
...

AugumentedFaceクラスはTrackableBaseクラスを継承しており、TrackingStateで顔の検出状態を持っています。
顔を検出している時はTrackingState.TRACKINGになりますが、顔の前に物体がある時はTrackingState.PAUSEDに変わります。
この状態の変化を利用して、前節では1フレーム毎に顔が検出された際にテクスチャのリストの先頭を設定していたので、TrackingState.PAUSEDになった時テクスチャのリストの先頭を削除して、また1フレーム毎に呼ばれるので次にきた時にリストの先頭が変わることで、変面に見えるようにしています。

実装は以上です。

おわりに

今回の実装に関するサンプルは下記リンクより試すことができます。ぜひやってみてください!
https://github.com/yutaro6547/FaceSample

はじめてArgumented Facesを実装してみましたが、比較的簡単にできたのでよかったら試してみてください。
何かの参考になったら嬉しいです。

まだまだ改善の余地があるので、また機会がありましたら書こうと思います。

参考リンク

Argumented Faces | ARCore
sceneform-android-sdk | GitHub

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

実機でメーラ起動する時だけintentで渡したメール本文が表示されません。

解決したいこと

自作のAndroidアプリからto、cc、メール本文を実機のGmailアプリに渡して表示させたいです。
どなたかお力添えいただけませんか?

状況

これまで何度か、実機で実行して上手くいっていたのですが急にGmailアプリにメール本文が表示されなくなってしまいました。
メール本文が表示されないのは、toを実機のGmailアプリに渡した時だけです。
yahooメールだと上手く表示できます。
Android StudioのエミュレータだとGmailアプリで本文も表示できます。

試したこと

実機の自作のAndroidアプリのアンインストール、再インストール。
実機のGmailアプリのアンインストール、再インストール。

対象のソース

        Intent intent = new Intent();

        intent.setAction(Intent.ACTION_SENDTO);

        // to "mailto:"だけにすれば上手くいきます。
        intent.setData(Uri.parse("mailto:xxx@gmail"));

        // cc
        String[] MailAddress_CC = {ccStr};
        intent.putExtra(Intent.EXTRA_CC, MailAddress_CC);

        // title
        intent.putExtra(Intent.EXTRA_SUBJECT, mailTitleStr);

        // text
        String mailText = "test";

        intent.putExtra(Intent.EXTRA_TEXT, mailText);
        startActivity(Intent.createChooser(intent, null));

よろしくお願いいたします。

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

dagger2入門 - basic(@Named & @Qualifier)

概要

@Namedを利用する

コード

結果

Red
Blue

Process finished with exit code 0

説明

  • @Injectで引数違いインスタンスを区別できないが、@Namedを利用して同じクラスのインスタンスを分けれる
  • しかし、@Named("SelfType_red")に「SelfType_red」の定義が使いにくい

@Qualifier

コード

結果

QualifierRed
QualifierBlue

Process finished with exit code 0

説明

  • @Qualifierで新しいannotationを定義して文字列(SelfType_red)より使いやすい
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

dagger2入門 - basic(@Module & @Provides)

概要

詳細

@Injectなしの基本クラスを定義する

class Age {
    fun myAge(): String {
        return "22"
    }
}

abstract class Citer {
    abstract fun doWhat(): String
}

class Student(val age: Age) {
    fun doWhat(): String {
        return age.myAge()
    }
}

class Worker : Citer() {
    override fun doWhat(): String {
        return "work"
    }
}

モジュールで上記のクラスを導入する

@Module
class CiterModule {
    @Provides
    fun provideAge(): Age {
        return Age()
    }
    @Provides
    fun providedStudent(age: Age): Student {
        return Student(age)
    }

    @Provides
    fun providedWorker(): Worker {
        return Worker()
    }
}

コンポネントを定義するときに、モジュールを導入する

@Component(modules = [CiterModule::class])
interface CiterComponent {
    fun inject(house: House)
}

同じ方法で注入する

class House {
    @Inject
    lateinit var student: Student

    @Inject
    lateinit var worker: Worker

    init {
        DaggerCiterComponent.create().inject(this)
    }

    fun showTime() {
        println(student.doWhat())
        println(worker.doWhat())
    }
}

fun main(args: Array<String>) {
    val house = House()
    house.showTime()
}

結果

22
work

Process finished with exit code 0

遷移図

スクリーンショット 2019-12-21 16.33.34.png

サンプルコード

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

Android で OpenCV と MobleNet を使って 物体検出する

AndroidStudio で OpenCV をインポートする
の続きです。

MobleNet

MoblieNetは、モバイル用に軽量化された Neural Network のフレームワークです。

OpenCV の MobleNet

OpenCV 3.3 から対応している。
cv::dnn モジュールと Java ラッパーが用意されている。

opencv : Deep Neural Networks (dnn module)

opencv : Class Dnn

mobilenet-objdetect

OpenCVのレポジトリにある Android 用のサンプルコードです。
今回はこれを試す。

学習済みデータ

上記のサンプルコードには、学習済みデータとして、
下記の2つのファイルが必要です。
- 構成ファイル MobileNetSSD_deploy.prototxt
- 重みファイル MobileNetSSD_deploy.caffemodel

下記からダウンロードする。
https://github.com/chuanqi305/MobileNet-SSD

検出できる物体は下記の20種類。

  • aeroplane (飛行機)
  • bicycle (自転車)
  • bird (鳥)
  • boat (小型船)
  • bottle (瓶)
  • bus (バス)
  • car (車)
  • cat (猫)
  • chair (椅子)
  • cow (乳牛)
  • diningtable (食卓)
  • dog (犬)
  • horse (馬)
  • motorbike (オートバイ)
  • person (人物)
  • pottedplant (鉢植え)
  • sheep (羊)
  • sofa (長椅子)
  • train (列車)
  • tvmonitor (テレビ受像機)

アプリを作成する

まず、下記を読んでください。
Android で OpenCV のサンプル camerapreview を試す

CameraActivity を継承して MainActivity.java を作成する。

Neural Network を取得する。

public class MainActivity extends CameraActivity implements CvCameraViewListener2 {

public void onCameraViewStarted(int width, int height) {
    // 学習済みモデルを設定する
    String proto = getPath("MobileNetSSD_deploy.prototxt", this);
    String weights = getPath("MobileNetSSD_deploy.caffemodel", this);
    // Neural Network を取得する
    mNet = Dnn.readNetFromCaffe(proto, weights);
}

private  String getPath(String fileName, Context context) {
        // Assets フォルダーのファイルを外部記憶にコピーする
        // 詳細 略

物体を検出する。

public Mat onCameraFrame(CvCameraViewFrame inputFrame) {
    final int IN_WIDTH = 300;
    final int IN_HEIGHT = 300;
    final double MEAN_VAL = 127.5;

    Mat frame = inputFrame.rgba();

    // 物体検出する物体の大きさを設定する
    Mat blob = Dnn.blobFromImage(frame,         IN_SCALE_FACTOR,
     new Size(IN_WIDTH, IN_HEIGHT),
    new Scalar(MEAN_VAL, MEAN_VAL, MEAN_VAL), 
    /*swapRB*/false, /*crop*/false);
    mNet.setInput(blob);

    // フォワードパスを実行し、物体を検出する
    Mat detections = mNet.forward();

アプリを実行する

アプリが起動すると、
カメラのアクセス許可を要求する。
opencv_permission.png

スクリーンショット
猫を検出した例
opencv52_cat.png

馬、人物、車、犬 を同時に検出した例
opencv52_mix.png

いろいろ試してみた。
1つの学習済みデータで、
20種類の物体がリアルタイムに検出できた。
クラウドを使わないモバイルアプリとしては、優秀だね。

サンプル画像
https://github.com/chuanqi305/MobileNet-SSD/tree/master/images

サンプルコードをgithub に公開した。
https://github.com/ohwada/Android_Samples/tree/master/Opencv52

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

android javaでユーザからカメラ、写真フォルダへの許可を取る方法

android javaでユーザからカメラ、写真フォルダへの許可を取る方法

★ソース    

public class MainActivity extends AppCompatActivity {

    private static final int REQUEST_CODE_CAMERA_PERMISSION = 1;
    private static final int REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION = 2;

    String title = "権限チェック";
    String message = "この機能を使用する際には権限を許可する必要があります。";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button req = (Button) findViewById(R.id.requestButton);
        req.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {

                // カメラの権限状態取得
                int permissionCamera = PermissionChecker.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA);

                // カメラの権限確認
                if (permissionCamera != PackageManager.PERMISSION_GRANTED) {
                    requestPermission(REQUEST_CODE_CAMERA_PERMISSION);
                }

                // カメラロールの権限状態取得
                int permissionWriteExternalStorage = PermissionChecker.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE);

                // カメラロールの権限確認
                if (permissionWriteExternalStorage != PackageManager.PERMISSION_GRANTED) {
                    requestPermission(REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION);
                }

               // TODO : アクセス後の処理

            }
        });

        return;

    }

    // Permission handling for Android 6.0
    private void requestPermission(int requestCode) {

        // 権限チェックした結果、持っていない場合はダイアログを出す
        switch (requestCode) {

            case REQUEST_CODE_CAMERA_PERMISSION:
                if (ActivityCompat.shouldShowRequestPermissionRationale(this,Manifest.permission.CAMERA)) {
                    alert(title, message);
                }
                break;

            case REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION:
                if (ActivityCompat.shouldShowRequestPermissionRationale(this,Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                    alert(title, message);
                }
                break;
        }
    }

    private void alert(String title,String message){

        DialogInterface.OnClickListener dialog = new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                ActivityCompat.requestPermissions(MainActivity.this,
                        new String[]{Manifest.permission.CAMERA},
                        REQUEST_CODE_CAMERA_PERMISSION);
            }
        };

        new AlertDialog.Builder(this)
                .setTitle(title).setMessage(message)
                .setPositiveButton(android.R.string.ok,dialog).create().show();
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidでKotlin Coroutinesを使ってソケット通信をしてみる

この記事は フラー Advent Calendar 2019 の21日目の記事です.
前日の20日目は@AtsushiIzuさんで iOS開発におけるBitrise活用事例 でした.

はじめに

私は現在,フラー株式会社でAndroidエンジニアとしてアルバイトをさせていただいております.
そこで初めてAndroidに触り,早6ヶ月以上が経過しましたが,最近大学でも研究に使うGUIのためにAndroidアプリを作っています.そのアプリでソケット通信を行うのですが,簡単に非同期処理ができて,ナウそうなCoroutinesを使ってみることにしました.

今回の内容

今回は,サーバからクライアントに決まったメッセージを送信するだけという,めちゃくちゃ簡単なアプリを作成します.
サーバ側はPythonで,クライアント側はKotlin(Android)を使用します.サーバもKotlinで書けよというツッコミはその通りすぎるので無しでお願いします.

環境

  • macOS Mojave バージョン 10.14.6
  • Nexus 5X API 27 (Emulator)
  • Python 3.7.4
  • Kotlin 1.3.50

ソケット通信とは

ソケットは一般にクライアントとサーバーの対話で使用されます。 通常のシステム構成では、一方のマシンにサーバーを、 もう一方のマシンにクライアントを置きます。 クライアントはサーバーに接続して情報を交換し、その後切断します。

引用元: https://www.ibm.com/support/knowledgecenter/ja/ssw_ibm_i_71/rzab6/howdosockets.htm

異なるコンピュータ間の通信をいい感じに行えるということです.今回は,TCPを使用したコネクション型通信を考えています.そのため,まずサーバは,クライアントがサーバを探せるようにアドレスを確立(バインド)し,クライアントの要求を待ちます.そして,クライアントからの要求が来たとき,サーバはそれに応え,応答を送信するといった感じです.

詳しくは参考資料をご確認ください!

Coroutinesとは

軽量スレッドであり,Android上で使用して非同期のコードを簡素化できるものです.

ウェブページの取得やAPIとのやり取り,DBからのデータ取得やディスクからの画像読み込みなどの重い処理はメインスレッドで行うと,その処理が終わるまでアプリが停止してしまいます.そこで,非同期処理が必要になるわけです.そして,Coroutinesを使用することにより,非同期処理を簡単に行わせることができるということですね.

詳しくは参考資料をご確認ください!!

やってみる

サーバ

サーバ側のソースコードはこんな感じです.同階層のconfig.py には自分のIPと任意のPORT番号が定義されています.

send.py
import socket
import time
from config import IP, PORT


def main():
    with socket.socket() as s:
        s.bind((IP, PORT))
        s.listen(1)
        while True:
            client, _ = s.accept()
            with client:
                client.sendall(b"Hello, socket.\n")
                time.sleep(3)
                client.sendall(b"Hello, socket!!!!!!\n")


if __name__ == "__main__":
    main()

サーバでは,接続してきたクライアントに対して,最初にHello, socket. というメッセージを送信して,3秒後にテンション高めなHello, socket!!!!!! というメッセージを送信するだけです.

クライアント

クライアント側の処理は,
1. サーバ側のIPとPORTを指定して接続
2. 送られてくるメッセージ受信
3. 受信したメッセージをViewに反映
という流れです.

実際にソケット通信を行っているのは以下のコードです.

model/SocketClient.kt

SocketClient.kt
package com.runn_dev.socketsample.model

import android.util.Log
import androidx.lifecycle.MutableLiveData
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.Socket

class SocketClient(private val ip: String, private val port: Int) {

  private lateinit var socket: Socket
  private lateinit var reader: BufferedReader
  val receivedData: MutableLiveData<String> = MutableLiveData()

  fun connect() {
    try {
      socket = Socket(ip, port)
      Log.d(TAG, "connected socket")
    } catch (e: Exception) {
      Log.e(TAG, "$e")
    }
  }

  fun read() {
    reader = BufferedReader(InputStreamReader(socket.getInputStream()))
    try {
      reader.use {
        while (true) {
          val message = it.readLine()
          if (message != null) {
            receivedData.postValue(message)
            Log.d(TAG, message)
          } else {
            break
          }
          Thread.sleep(SLEEP_TIME)
        }
      }
    } catch (e: Exception) {
      Log.e(TAG, "$e")
    }
  }

  fun close() {
    if (::reader.isInitialized) {
      reader.close()
      Log.d(TAG, "closed reader")
    }
    if (::socket.isInitialized) {
      socket.close()
      Log.d(TAG, "closed socket")
    }
  }

  companion object {
    const val TAG = "SocketClient"
    const val SLEEP_TIME = 500L
  }
}

これをViewModelから呼び出して処理するのですが,Androidでは上述の通り,こういった通信をメインスレッドで行うことはできません.そこでこれらの処理を別スレッドで実行させます.

ResultViewModel.kt
  private fun read() = viewModelScope.launch {
    runCatching {
      withContext(Dispatchers.IO) {
        socketClient.connect()
        socketClient.read()
      }
    }.onFailure {
      Log.e(TAG, it.toString())
    }
  }

ここで,withContext(Dispatchers.IO) を呼び出して,コルーチンを別スレッドで実行させています.今回は,ソケット通信を行う処理をしているため,Dispatchers.IO を使用していますが,他にもいくつか種類があります.

また,KotlinではコルーチンをCoroutineScope内で実行し,コルーチンを管理する必要があります.例えば,ユーザが画面から離れた場合に処理をキャンセルするなどの処理をデベロッパが行う必要があります.しかし,viewModel内でコルーチンを使う場合,viewModelScopeを使用することによって,viewModelのライフサイクルにあわせて自動的に処理をキャンセルしてくれます.

コルーチンを Android アーキテクチャ コンポーネントに統合する際、デベロッパーは通常、ViewModel 内でコルーチンを launch する必要があります。この場所は、最も重要な処理が開始される場所であり、すべてのコルーチンを終了させるローテーションについて心配する必要がないため、自然な場所と言えます。

引用元: https://developers-jp.googleblog.com/2019/07/coroutines-on-android-part-ii-getting-started.html

GoogleもコルーチンをviewModel内で使うことを推奨しているので,基本的にはviewModelScopeを使うのが良いかなと思います.

結果

まずサーバを立ち上げます.

$ python send.py

そして,Androidアプリを立ち上げ,IPとPORTを入力して接続します.
このとき,クライアント側では,入力されたIPとPORTを遷移先のフラグメントに渡します.そして,渡されたIPとPORTを使用してサーバに接続し,LiveDataとDataBindingによってサーバから応答がある度にViewを変更します.

ちゃんと受信できてるっぽいですね(2, 3枚目).そして,ちゃんと3秒後にはテンション高めなViewに変わっています.

全ソースコードはこちら

おわりに

めちゃくちゃ簡単にではありましたが,Coroutinesを使用した非同期処理でソケット通信をすることができました.本当はメッセージを双方向でやりとりしたかったですが,間に合わなかったので今後の課題ということにさせてください...
また,Android歴もそんなに長くないので,ここはこうしたほうが良いとかたくさんあると思います.何かあったらガンガン指摘してください!

参考資料

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

SharedPreferenceをDelegatedPropertiesでスリムに[Google I/O Android Appより]

本記事はand factory Advent Calendar 2019 21日目の記事です!

こんにちは。Androidエンジニアの@nabetaro_jpです。The Google I/O 2019 Android AppのDataをLocalに保存するレイヤーのコードからSharedPreferencesに値を保存する部分で参考になる部分のメモとしてコードの内容を、簡略化してまとめたいと思います。

PreferenceStorage

Preferenceに保存する値のメンバーを定義するインターフェース

PreferenceStorage.kt
interface PreferenceStorage {
    var id: Int
    var name: String?
    var isSubscribed: Boolean
}

ReadWritePropertyを継承したクラス:StringPreference, IntPreference, BooleanPreference

ReadWritePropertyとは読み取りや書き込みのプロパティのproperty delegatesを実装するのに使用できるインターフェースです。便宜上のみの提供なので、基本このインターフェースを拡張する必要はないのですが、データの永続化のために拡張している模様です。

interface ReadWriteProperty<in R, T>

参考:https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.properties/-read-write-property/index.html

Preference.kt
class BooleanPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: Boolean
) : ReadWriteProperty<Any, Boolean> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): Boolean {
        return preferences.value.getBoolean(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) {
        preferences.value.edit { putBoolean(name, value) }
    }
}

class StringPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: String?
) : ReadWriteProperty<Any, String?> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): String? {
        return preferences.value.getString(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: String?) {
        preferences.value.edit { putString(name, value) }
    }
}

class IntPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: Int = 0
) : ReadWriteProperty<Any, Int> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): Int {
        return preferences.value.getInt(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: Int) {
        preferences.value.edit { putInt(name, value) }
    }
}

SharedPreferenceStorage : PreferenceStorage

SharedPreferenceStorage.kt
@Singleton
class SharedPreferenceStorage @Inject constructor(context: Context) : PreferenceStorage {

    private val prefs: Lazy<SharedPreferences> = lazy {
        // Lazy to prevent IO access to main thread.
        context.applicationContext.getSharedPreferences(
            PREFS_NAME, MODE_PRIVATE
        )
    }

    override var id: Int by IntPreference(prefs, PREF_ID)
    override var name: String? by StringPreference(prefs, PREF_NAME, "")
    override var isSubscribed: Boolean by BooleanPreference(prefs, PREF_IS_SUBSCRIBED, false)

    companion object {
        const val PREFS_NAME = "my_preference"
        const val PREF_ID = "pref_id"
        const val PREF_NAME = "pref_name"
        const val PREF_IS_SUBSCRIBED = "pref_is_subscribed"
    }

}

注目したのはここ?

override var name: String? by StringPreference(prefs, PREF_NAME, "")

DelegatedProperties(委譲プロパティ)ですね。byを記載することで nameへの読み書き、つまりGetter/Setterの処理を byで指定している StringPreferenceで行うよ〜 ってことですね。

参考:https://sys1yagi.gitbooks.io/anatomy-kotlin/content/%E3%83%87%E3%83%AA%E3%82%B2%E3%83%BC%E3%83%88%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3%E3%81%AE%E6%AD%A3%E4%BD%93.html

終わり

ボイラープレートも減ってスッキリとprefに保存する処理がかけることでしょう。 Kotlinに感謝ですね〜?

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

[コピペで使える]SharedPreferenceをDelegatedPropertiesで[Google I/O Android Appより]

本記事はand factory Advent Calendar 2019 21日目の記事です!

こんにちは。Androidエンジニアの@nabetaro_jpです。The Google I/O 2019 Android AppのDataをLocalに保存するレイヤーのコードからSharedPreferencesに値を保存する部分で参考になる部分のメモとしてコードの内容を、簡略化してまとめたいと思います。

PreferenceStorage

Preferenceに保存する値のメンバーを定義するインターフェース

PreferenceStorage.kt
interface PreferenceStorage {
    var id: Int
    var name: String?
    var isSubscribed: Boolean
}

ReadWritePropertyを継承したクラス:StringPreference, IntPreference, BooleanPreference

ReadWritePropertyとは読み取りや書き込みのプロパティのproperty delegatesを実装するのに使用できるインターフェースです。便宜上のみの提供なので、基本このインターフェースを拡張する必要はないのですが、データの永続化のために拡張している模様です。

interface ReadWriteProperty<in R, T>

参考:https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.properties/-read-write-property/index.html

Preference.kt
class BooleanPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: Boolean
) : ReadWriteProperty<Any, Boolean> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): Boolean {
        return preferences.value.getBoolean(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) {
        preferences.value.edit { putBoolean(name, value) }
    }
}

class StringPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: String?
) : ReadWriteProperty<Any, String?> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): String? {
        return preferences.value.getString(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: String?) {
        preferences.value.edit { putString(name, value) }
    }
}

class IntPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: Int = 0
) : ReadWriteProperty<Any, Int> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): Int {
        return preferences.value.getInt(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: Int) {
        preferences.value.edit { putInt(name, value) }
    }
}

SharedPreferenceStorage : PreferenceStorage

SharedPreferenceStorage.kt
@Singleton
class SharedPreferenceStorage @Inject constructor(context: Context) : PreferenceStorage {

    private val prefs: Lazy<SharedPreferences> = lazy {
        // Lazy to prevent IO access to main thread.
        context.applicationContext.getSharedPreferences(
            PREFS_NAME, MODE_PRIVATE
        )
    }

    override var id: Int by IntPreference(prefs, PREF_ID)
    override var name: String? by StringPreference(prefs, PREF_NAME, "")
    override var isSubscribed: Boolean by BooleanPreference(prefs, PREF_IS_SUBSCRIBED, false)

    companion object {
        const val PREFS_NAME = "my_preference"
        const val PREF_ID = "pref_id"
        const val PREF_NAME = "pref_name"
        const val PREF_IS_SUBSCRIBED = "pref_is_subscribed"
    }

}

注目したのはここ?

override var name: String? by StringPreference(prefs, PREF_NAME, "")

DelegatedProperties(委譲プロパティ)ですね。byを記載することで nameへの読み書き、つまりGetter/Setterの処理を byで指定している StringPreferenceで行うよ〜 ってことですね。

参考:https://sys1yagi.gitbooks.io/anatomy-kotlin/content/%E3%83%87%E3%83%AA%E3%82%B2%E3%83%BC%E3%83%88%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3%E3%81%AE%E6%AD%A3%E4%BD%93.html

終わり

ボイラープレートも減ってスッキリとprefに保存する処理がかけることでしょう。 Kotlinに感謝ですね〜?

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

[コピペで使える]SharedPreferenceをDelegatedPropertiesでスリムに[Google I/O Android Appより]

本記事はand factory Advent Calendar 2019 21日目の記事です!

こんにちは。Androidエンジニアの@nabetaro_jpです。The Google I/O 2019 Android AppのDataをLocalに保存するレイヤーのコードからSharedPreferencesに値を保存する部分で参考になる部分のメモとしてコードの内容を、簡略化してまとめたいと思います。

PreferenceStorage

Preferenceに保存する値のメンバーを定義するインターフェース

PreferenceStorage.kt
interface PreferenceStorage {
    var id: Int
    var name: String?
    var isSubscribed: Boolean
}

ReadWritePropertyを継承したクラス:StringPreference, IntPreference, BooleanPreference

ReadWritePropertyとは読み取りや書き込みのプロパティのproperty delegatesを実装するのに使用できるインターフェースです。便宜上のみの提供なので、基本このインターフェースを拡張する必要はないのですが、データの永続化のために拡張している模様です。

interface ReadWriteProperty<in R, T>

参考:https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.properties/-read-write-property/index.html

Preference.kt
class BooleanPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: Boolean
) : ReadWriteProperty<Any, Boolean> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): Boolean {
        return preferences.value.getBoolean(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) {
        preferences.value.edit { putBoolean(name, value) }
    }
}

class StringPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: String?
) : ReadWriteProperty<Any, String?> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): String? {
        return preferences.value.getString(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: String?) {
        preferences.value.edit { putString(name, value) }
    }
}

class IntPreference(
    private val preferences: Lazy<SharedPreferences>,
    private val name: String,
    private val defaultValue: Int = 0
) : ReadWriteProperty<Any, Int> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>): Int {
        return preferences.value.getInt(name, defaultValue)
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: Int) {
        preferences.value.edit { putInt(name, value) }
    }
}

SharedPreferenceStorage : PreferenceStorage

SharedPreferenceStorage.kt
@Singleton
class SharedPreferenceStorage @Inject constructor(context: Context) : PreferenceStorage {

    private val prefs: Lazy<SharedPreferences> = lazy {
        // Lazy to prevent IO access to main thread.
        context.applicationContext.getSharedPreferences(
            PREFS_NAME, MODE_PRIVATE
        )
    }

    override var id: Int by IntPreference(prefs, PREF_ID)
    override var name: String? by StringPreference(prefs, PREF_NAME, "")
    override var isSubscribed: Boolean by BooleanPreference(prefs, PREF_IS_SUBSCRIBED, false)

    companion object {
        const val PREFS_NAME = "my_preference"
        const val PREF_ID = "pref_id"
        const val PREF_NAME = "pref_name"
        const val PREF_IS_SUBSCRIBED = "pref_is_subscribed"
    }

}

注目したのはここ?

override var name: String? by StringPreference(prefs, PREF_NAME, "")

DelegatedProperties(委譲プロパティ)ですね。byを記載することで nameへの読み書き、つまりGetter/Setterの処理を byで指定している StringPreferenceで行うよ〜 ってことですね。

参考:https://sys1yagi.gitbooks.io/anatomy-kotlin/content/%E3%83%87%E3%83%AA%E3%82%B2%E3%83%BC%E3%83%88%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3%E3%81%AE%E6%AD%A3%E4%BD%93.html

終わり

ボイラープレートも減ってスッキリとprefに保存する処理がかけることでしょう。 Kotlinに感謝ですね〜?

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

Fragment+RecyclerView+EditTextでホワイトスクリーン

RecyclerViewの中にEditTextを入れて、カーソルをあてると、画面が真っ白になる。
けど、1文字入力すると、画面が表示されるが、キーボードを下げるボタンをクリックするとやはり、画面が白くなる。

キーボード下げるボタン

スクリーンショット 2019-12-21 13.22.34.png

原因

ConstraintLayoutで全体を括っているのですよね。これがアカンかった。

問題の事象が発生するLayout
<?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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.core.widget.NestedScrollView
        android:id="@+id/nestedScrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:overScrollMode="never"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <LinearLayout
            android:id="@+id/linearLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:text="タイトル"
            android:textAlignment="center"
            android:textSize="20sp" />

        <Button
            android:id="@+id/submit"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_gravity="center"
            android:text="送信" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/trip_list"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="36dp"
            android:layout_marginEnd="36dp"
            android:layout_marginTop="16dp"
            android:layout_gravity="top"/>

        </LinearLayout>>
    </androidx.core.widget.NestedScrollView>

</androidx.constraintlayout.widget.ConstraintLayout>
問題の事象が発生しないLayout
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:overScrollMode="never"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <LinearLayout
        android:id="@+id/linearLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:text="タイトル"
            android:textAlignment="center"
            android:textSize="20sp" />

        <Button
            android:id="@+id/submit"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_gravity="center"
            android:text="送信" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/trip_list"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="36dp"
            android:layout_marginEnd="36dp"
            android:layout_marginTop="16dp"
            android:layout_gravity="top"/>

    </LinearLayout>
</androidx.core.widget.NestedScrollView>

エラーログとか出ないし、fragmentの組み方(Java)が悪いのか?とか諸々調べて、めっちゃ時間かかった。

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

MotionLayoutを使って無限Pagerを実装する

この記事は、はてなエンジニア Advent Calendar 2019 の 23 日目の記事です。

MotionLayout でこのような無限 Pager を実装してみたというちょっとしたお遊びです。

MotionLayout で Pager を作る考え方

  • 表示させているもの + 前後に表示されず隠れている View を用意する

  • スワイプさせて次の View が表示されたタイミングで、表示をそのままに Motion を最初の状態に戻す

といった感じで Pager を実現させています。

実装

MotionScene の定義

ConstraintSet を最初の状態、右にスワイプした時、左にスワイプした時の 3 パターン用意します。
最初の状態を起点に Transition を右にスワイプした時、左にスワイプした時で 2 つ用意します。  

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">

    <ConstraintSet android:id="@+id/base_state">
        <Constraint android:id="@id/centerView">
            <Layout
                android:layout_width="320dp"
                android:layout_height="320dp"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toTopOf="parent" />

        </Constraint>

        <Constraint android:id="@id/leftView">
            <Layout
                android:layout_width="320dp"
                android:layout_height="320dp"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintEnd_toStartOf="parent"
                motion:layout_constraintTop_toTopOf="parent" />
        </Constraint>

        <Constraint android:id="@id/rightView">
            <Layout
                android:layout_width="320dp"
                android:layout_height="320dp"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintStart_toEndOf="parent"
                motion:layout_constraintTop_toTopOf="parent" />
        </Constraint>
    </ConstraintSet>

    <ConstraintSet android:id="@+id/move_left_to_right">
        <!-- 省略 -->
        <!-- 左へスワイプした時のViewの状態を定義する -->
    </ConstraintSet>

    <ConstraintSet android:id="@+id/move_right_to_left">
        <!-- 省略 -->
        <!-- 右へスワイプした時のViewの状態を定義する -->
    </ConstraintSet>

    <Transition
        motion:constraintSetEnd="@id/move_left_to_right"
        motion:constraintSetStart="@id/base_state">
        <OnSwipe
            motion:dragDirection="dragRight"
            motion:onTouchUp="autoCompleteToStart"
            motion:touchAnchorId="@id/centerView"
            motion:touchAnchorSide="right" />
    </Transition>

    <Transition
        motion:constraintSetEnd="@id/move_right_to_left"
        motion:constraintSetStart="@id/base_state">
        <OnSwipe
            motion:dragDirection="dragLeft"
            motion:onTouchUp="autoCompleteToStart"
            motion:touchAnchorId="@id/centerView"
            motion:touchAnchorSide="left" />
    </Transition>

</MotionScene>

アイテムの表示処理の実装

アニメーションが完了したら(スワイプで完全に移動した時)、リストの位置をずらしてアニメーションを最初に戻す (motionLayout?.progress = 0F) だけです。

motionLayout.setTransitionListener(object : MotionLayout.TransitionListener {
    override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
        when (currentId) {
            R.id.move_left_to_right -> {
                if (currentPosition > 0) {
                    currentPosition--
                } else {
                    currentPosition = itemList.lastIndex
                }
                motionLayout?.progress = 0F
                updateView()
            }
            R.id.move_right_to_left -> {
                if (currentPosition < itemList.lastIndex) {
                    currentPosition++
                } else {
                    currentPosition = 0
                }
                motionLayout?.progress = 0F
                updateView()
            }
        }
    }
})

MotionLayout を使っているため KeyFrameSet を調整することで様々なアニメーションを実現できます。

全コードはこちらに公開しています
https://github.com/NUmeroAndDev/MotionLayoutPager-android

MotionLayout で無限 Pager を実装してみました。
プロダクトに当てはめるには考慮すべきことや表現できないパターンがありそうですが、ぬるぬるアニメーションさせるなら MotionLayout は便利だなと思いました。
来年には MotionEditor が使えるようになりそうなので、MotionLayout をもっと使っていきたいですね :dancer:

明日のはてなエンジニア Advent Calendar 2019 は @hitode909 さんです!

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

アプリテスト自動化への旅【AppiumとJestで簡単に試してみよう!】

皆さんはアプリのテスト自動化をしたことがありますか?
おそらく手元で手動テストをし、Excelか何かにまとめられたテスト仕様書にチェックを入れている人もいるのではないでしょうか。

しかし、自動化しようとしても、どこまで自動化ができるのだろうか。。。だとか、こんな表現をテストで実装は出来ないよね?だとか、時間かかりそうだし、今はそういうフェーズじゃない。だとか...そう思っている人は結構いるのではないかと私は思っています。

そんな人たちに、意外とアプリのテスト自動化はイケるぞ!ってのを伝えるために、本記事にて、AppiumJestを組み合わせた自動テストを紹介します。

皆さんの手元で簡単にセットアップできるので、是非お試しください!

なお、今回紹介する内容のソースコードは以下のリポジトリから参照できますので、是非ご利用ください!
https://github.com/minakawa-daiki/AppiumJestSample

目標

今回の記事では、以下が出来ることをゴールにしていきたいと思います。

  • ページがしっかりと表示されている
  • テキストを自動入力できる
  • ボタンが押せる
  • スワイプができる
  • ブラウザアプリを開いて、テスト対象のアプリに戻ってくる
  • スクリーンショットを保存してみる
  • 一連のテストフローを動画として保存する

Appiumについて

公式サイト: https://appium.io/

公式サイトの言葉を借りて、Google翻訳すると

Appiumは、ネイティブ、 ハイブリッド、およびモバイルWebアプリで利用可能なオープンソーステスト自動化フレームワークです。
WebDriverプロトコルを使用してiOS、Android、およびWindowsアプリで動きます。

要するにアプリのテストを自動化できます。使う前は、どこまで自動化できるのだろうか?と疑問でしたが、かなり自動化できる印象です。様々な言語に対応しているので、とても使いやすいです。

また、GUIのデバッグツールなども用意されているので、とても開発者に優しい、素晴らしいプロダクトだと思います。

Appium

画像は https://www.3pillarglobal.com/insights/appium-a-cross-browser-mobile-automation-tool より

Appiumは様々な言語に対応していますが、今回はJest + TypeScriptで記述していきます。
また、ライブラリはwdを使って書かれたサンプルが多いですが、今回はwebdriverioでやっていきます。

下準備

AndroidとiOSの両方のテスト自動化を行なっていきたいため、 React Nativeでサンプルアプリを作成します。
今回使用するバージョンは以下の通りになります。

  • Expo SDK: v36
  • Appium: 1.16.0-beta.3

Appiumは執筆当時、iOS10系周りでバグが出ていたため、betaを使用しています。
https://github.com/appium/appium/issues/13627

iOS Simulatorの準備

テストに使用するiOS Simulatorの準備をまずしましょう。

Xcodeを開いて、使用したいOSバージョンのシミュレーターをまずはインストールします。

image.png

インストールした後、Simulatorの一覧に存在する端末であれば、その端末を利用してテストが実行されます。
存在しない端末だと毎回新しいSimulatorが作られてしまうので、テストが落ちる原因になるので少し注意が必要です。

image.png

今回の例だと、iPhone11 Pro MaxのiOS 13.2という感じです。

Android Emulatorの準備

AndroidのEmulatorも以下の手順に従って準備していきます。

  1. Android Studioで適当なサンプルアプリを開いてAVDマネージャを開きます image.png
  2. Create Virtual Deviceからエミュレータを追加します
  3. Nameの部分を利用するのでメモっておくと良いでしょう

ここで注意点なのですが、Android 6系以下のEmulatorはChromeではないため(実機はChrome)、WebViewを使用しているアプリでは、そもそもテスト自動化の検証端末に使用しない方がいいです。実機を利用しましょう。

参考: https://qiita.com/masakura/items/210261c954256a7879e6

Appium doctorの実行と修正

projectフォルダに移動し、 yarn appium-doctor を実行しましょう。そして、エラーが出た部分を直していきます。

Macユーザーの方は以下の項目でエラーが出る確率が高いと思います。

  • ✖ Xcode is NOT installed!
  • ✖ Carthage was NOT found!
  • ✖ ANDROID_HOME is NOT set!
  • ✖ JAVA_HOME is NOT set!
  • ✖ adb could not be found because ANDROID_HOME is NOT set!
  • ✖ android could not be found because ANDROID_HOME is NOT set!
  • ✖ emulator could not be found because ANDROID_HOME is NOT set!
  • ✖ Bin directory for $JAVA_HOME is not set

まず、 Xcode is NOT installed!https://github.com/nodejs/node-gyp/issues/569#issuecomment-94917337 を参考に、Xcodeをインストールした後、 udo xcode-select -s /Applications/Xcode.app/Contents/Developer を実行しましょう。

Carthage was NOT found!brew install carthage してください。

ANDROID_HOME is NOT set!JAVA_HOME is NOT set! は以下のような感じで環境変数を設定しましょう。

export ANDROID_HOME=$HOME/Library/Android/sdk
export JAVA_HOME=`/usr/libexec/java_home`
export PATH=$JAVA_HOME/bin:$PATH

そして、もう一度 yarn appium-doctor をすると解消しているはずです。

そして今回は、テストの実行状況を録画するため、ffmpegをインストールしておきます

brew install ffmpeg

今回のサンプルアプリの概要

今回使用するサンプルアプリは以下のようなページを持っています。コードの内部実装は詳しく説明しませんが、ソースコードは公開しているので、気になる方はご覧ください。

  • 1ページ目はテキストを入力する画面があります
  • 2ページ目は1ページ目で入力されたテキストが表示されます。ボタンを押すことで内容が変わります
  • 3ページ目は画像が配置されたWebViewで、画像をクリックするとSafariに遷移します
  • 4ページ目は空のページです
  • 5ページ目は動画が配置されたWebViewで、動画をクリックするとSafariに遷移します

hoge.gif

アプリを取得する

GitHubにてapkとappを公開してますが、自分でビルドしたい場合はexpo経由で以下のコマンドを実行してください。

自分でビルドする場合

  • iOSの場合は yarn build:ios
  • Androidの場合は yarn build:android

直接ダウンロードする場合

https://github.com/minakawa-daiki/AppiumJestSample/releases/tag/v1.0

また、生成されたアプリはappフォルダ直下にAppiumJestSample.appAppiumJestSample.apkでそれぞれ配置してください。(AppiumJestSample.app.zipを解凍してご利用ください)

テスト自動化対象端末のconfigを設定する

今回はテストコードを書く上のドライバーとしてwebdriverioを採用していますので、jest-environment-webdriverioというライブラリを使用しています。
https://www.npmjs.com/package/webdriverio
https://www.npmjs.com/package/jest-environment-webdriverio

また、よくAppiumのテストで使用されているドライバーでは wd があります。
https://www.npmjs.com/package/wd

webdriverioを採用した理由は、非同期処理をより綺麗に書ける点もありますが、執筆時点のwdでは動画撮影周りにバグがあったため採用を見送りました。

jest-environment-webdriverioのおかげで、webdriverioの設定を簡単に記述できます。jest.config.js を実際に見てみましょう。

必要な設定はtestEnvironmentjest-environment-webdriverioを指定し、testEnvironmentOptionsに端末情報を記述するだけです。

iOSでは以下のような設定になります。

jest.config.js
{
  port: 4723,
  capabilities: {
    platformName: "iOS",
    platformVersion: "11.3",
    deviceName: "iPhone X",
    automationName: "XCUITest",
    wdaLocalPort: 8100,
    nativeWebTap: true,
    app: "./app/AppiumJestSample.app"
  }
}
  • portはAppiumが起動しているポートを指定します
  • platformVersionはiOSのバージョンです
  • deviceNameはiOS Simulatorに存在する端末を指定します
  • automationNameは実際に使用するUIテストフレームワークを指定します
  • wdaLocalPortは並列化するときに違うポート番号を指定します
  • appはiOSのアプリケーションのpathを指定します

Androidはこんな感じです。

jest.config.js
{
  port: 4723,
  capabilities: {
    platformName: "Android",
    deviceName: "Android Emulator",
    automationName: "Appium",
    avd: "Pixel_3_API_29",
    systemPort: 8200,
    nativeWebTap: true,
    app: "./app/AppiumJestSample.apk"
  }
}
  • deviceNameはエミュレータを使用する場合はAndroid Emulatorを指定します
  • avdはエミュレータのnameのスペースを_で埋めたものを記述します
  • systemPortは並列化するときに違うポート番号を指定します

また、実機を接続してる場合は、以下のような設定で動かせます。

jest.config.js
{
  port: 4723,
  capabilities: {
    platformName: "Android",
    systemPort: 8201,
    app: "./app/AppiumJestSample.apk"
  }
}

自動テストを動かす

初回は自動化するためのアプリをインストールするため、時間がかかります。テストが失敗することが多々あるので、その場合は再度実行しましょう。

テストを実行する前にバックグラウンドで yarn appium を実行し、Appiumサーバーを立ち上げておきましょう。

その上で、テストの実行はyarn testで動作します。デフォルトはiOSが起動します。
明示的にOSを指定したい場合はそれぞれyarn test:android, yarn test:iosを実行してください。

テストが開始されると、シミュレータやエミュレータが起動し、テストが行われます。
全てのテストが終わったら、tests/screenshotsにテスト途中のスクショや全体の動画が以下のような感じで保存されていると思います。

image.png

hoge.gif

Appiumのスタイルはjestなどのテストフレームワークで表示チェックを担保するのと、スクリーンショットや動画なのでアプリの全体的な動きのテストを担保することで自動化していくのが良いと思っています。

無理に全てテストコードで担保しようとせず、スクリーンショットや動画も積極的に使っていきましょう。

テストコードの説明

まず、ReactNativeとNativeで書かれたコードのテストコードは結構違う感じになることを念頭に置いておいてください。
また、今回はほんの一部しか機能を紹介しないので興味を持った方はドキュメントを見てみると良いでしょう。

ドキュメントの例: http://appium.io/docs/en/commands/element/find-element/

それに加え、テストコードを書くときにはアプリ内のそれぞれのElementに対してaccessibilityLabelを振っておくと要素を特定しやすくなるのでオススメです。

動画を撮影する

index.test.ts
beforeAll(async () => {
  try {
    await browser.startRecordingScreen({ videoType: 'mpeg4' });
  } catch (e) {}
});

afterAll(async () => {
  try {
    const movie = await browser.stopRecordingScreen();
    const decode = Buffer.from(movie, 'base64');
    fs.writeFileSync(
      `${baseResultPath}/result.mp4`,
      decode
    );
  } catch (e) {}
});

動画は簡単に撮れます。browser.startRecordingScreen({ videoType: 'mpeg4' })で撮影を開始した後、browser.stopRecordingScreen()で結果を保持し、後はデコードして書き出すだけです。

ffmpegをインストールしておくのを忘れずに。

「1ページ目が存在している」かどうかのテスト

index.test.ts
test('1ページ目が存在している', async () => {
  expect((await browser.$('~slide1')).elementId).not.toBeUndefined();
  await browser.saveScreenshot(`${baseResultPath}/page1.png`);
});

browser.$('~slide1')).elementIdundefinedとならない場合、そのページは確かに存在している。という風にしてテストを担保しています。これは各ページで行っています。

また、browser.saveScreenshot('${baseResultPath}/page1.png')でスクリーンショットを保存しています。簡単ですね!

「1ページ目で入力された内容が2ページ目で表示されている」かどうかのテスト

index.test.ts
test('1ページ目で入力された内容が2ページ目で表示されている', async () => {
  const textInputElement = await browser.$('~TextInput');
  await textInputElement.addValue('おりばー');
  await browser.pause(2000);
  await browser.hideKeyboard();
  await swipe('left');
  // 2ページ目の存在確認
  expect((await browser.$('~slide2')).elementId).not.toBeUndefined();

  // https://github.com/appium/appium/issues/13288 をみる限り
  // iOSのバージョンによってはgetText()がaccessibilityLabelと同じ文字列を返すバグがある
  // なのでaccessibilityLabelの値をinputTextと一緒にしている
  // 最新のiOSとAndroidは問題ない
  const textInputResultElement = await browser.$(`~${inputText}`);
  expect(await textInputResultElement.getText()).toBe(inputText);
});

async function swipe(direction: 'left' | 'right') {
  if(platformName === 'iOS') {
    // iOSはswipeを呼び出せるのでそれを使う
    await browser.execute('mobile: swipe', { direction });
  } else if(platformName === 'Android') {
    // Androidは代わりにflickを利用する
    const rootElement = await browser.$('//*');
    const x = direction === 'left' ? -1000 : 1000;
    await browser.touchFlick(x, 0, rootElement.elementId, 100);

    // 現状ReactNativeはこれだとSwipeできない?(AndroidのNativeだと動くことを確認)
    // await browser.touchPerform([
    //   { action: 'press', options: { x: 1000, y: windowSize.height / 2 } },
    //   { action: 'moveTo', options: { x: 100, y: windowSize.height / 2 } },
    //   { action: 'release' },
    // ]);
  }
}

ここでは、1ページにて、文字を入力し、それが2ページ目で入力された内容が表示されているかどうかのテストを行っています。

textInputElement.addValue('おりばー')とすることで、textInputElementに対して「おりばー」という文字が入力されます。

また、browser.hideKeyboard()でキーボードを閉じることができるのも意外と盲点だったりします。

swipe('left')はスワイプを行なっています。iOSとAndroidでスワイプの仕方が様々あるので、気になる方はコードを読んでみてください。

browser.$(~${inputText})ここの部分はコメントにも書いているのですが、iOSの特定のバージョンにおいて、getText()accessibilityLabelで指定した内容になってしまうというバグが存在しています。

追記: Xcodeのバージョンも大きく関係しているらしいです。詳しくはコメント欄をご覧ください。

ですので、その応急処置として、accessibilityLabelにそのまま値を入れるという実装になってしまっています。

「2ページ目のボタンをタップすると内容が変化する」かどうかのテスト

index.test.ts
test('2ページ目のボタンをタップすると内容が変化する', async () => {
  await browser.saveScreenshot(`${baseResultPath}/page2-1.png`);
  const textChangeButtonElement = await browser.$('~textChangeButton');
  await textChangeButtonElement.click();
  const textInputResultElement = await browser.$('~タップされたよ!');
  await browser.saveScreenshot(`${baseResultPath}/page2-2.png`);
  expect(await textInputResultElement.getText()).not.toBe(inputText);
});

ここではtextChangeButtonElement.click()でボタンをタップして、文字の内容が変わっていることをテストしています。

「3ページ目が存在している」かどうかのテスト

index.test.ts
test('3ページ目が存在している', async () => {
  await swipe('left');
  expect((await browser.$('~slide3')).elementId).not.toBeUndefined();
  await browser.saveScreenshot(`${baseResultPath}/page3.png`);

  // 画像をクリックして戻ってこれることを確認
  await browser.pause(2000);
  const imageWrap = await browser.$("~imageWrap");
  await imageWrap.click();
  await browser.pause(5000);
  // アプリに戻ってくるための処理
  if (platformName === 'Android') {
    await browser.pressKeyCode(4);
  } else {
    await browser.execute('mobile: activateApp', {
      bundleId: app.expo.ios.bundleIdentifier,
    });
  }
});

3ページ目では存在確認に加え、「画像をクリックして、別アプリに行った後、戻ってこれることを確認」しています。
別アプリに遷移してしまった後はAndroidとiOSとで指定の仕方は変わるのですが、それぞれ以下のコマンドで可能になっています。

Androidの場合
browser.pressKeyCode(4)

iOSの場合
browser.execute('mobile: activateApp', {bundleId:app.expo.ios.bundleIdentifier,})

簡単ですね!

「5ページ目が存在している」かどうかのテスト

index.test.ts
test('5ページ目が存在している', async () => {
  await swipe('left');
  expect((await browser.$('~slide5')).elementId).not.toBeUndefined();

  // 動画が再生されていることをスクショで判別する
  await browser.saveScreenshot(`${baseResultPath}/page5-1.png`);
  await browser.pause(3000);
  await browser.saveScreenshot(`${baseResultPath}/page5-2.png`);
});

5ページ目では動画が自動再生されますので、スクリーンショットを撮って、しっかりと再生されているかをチェックしています。

こんな感じでスクリーンショットを撮りつつ、テストを実行していく流れになります。

余談

今回のテストの中で、Androidの場合はChromeDriverを利用するのですが、ChromeDriverはChromeのバージョンと強く紐づいています。
ですので、本来であればAndroidのChromeバージョンによって使い分けなければいけないのですが、Appiumの起動時にchromedriver_autodownloadと指定することで、Appium側でそこをよしなにやってくれています。すごいですね!

並列化について

並列化はwebdriverioが提供しているものもありますが、それを利用して並列化すると特定の端末のテストが落ちたときに全てのテストが失敗してしまうことになるのでオススメはあまりしないです。

なので、自分で別プロセスでそれぞれ実行して並列化させることをオススメします。

並列化するときの注意点はiOSの場合はwdaLocalPortを、Androidの場合はsystemPortをずらして実行することです。

終わりに

アプリのテスト自動化はAppiumのおかげで想像よりかは書きやすいです。
しかし、バグも多く潜んでいるので、根気よく戦っていく形にはやっぱりなってしまいます。
無理して全てを自動化するのではなく、今手動でやっているテストを少しでも簡単にしようという目線で、少しずつ自動化していくことが良いのではないかと思います。是非皆さんもAppiumに触れてみてください。

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

海外講演を通じて得られた知見(英語力+α編)

この記事は NTTテクノクロス Advent Calendar 2019の25日目(最終日)です。

こんにちは。NTTテクノクロスで、エバンジェリストとして活動している神原です。普段は主にモバイル関連の開発や技術支援、国内外の講演、ソフト道場研修講師社外向け技術ブログ執筆(モバイル関連)CSR活動(学生向けIT/キャリア教育)などを行なっています。また、プライベートでは、ランニングと英会話に目覚めて、日夜(?)取り組んでいます。

海外講演の変遷と気づき

今回は、私がエバンジェリストとして取り組んでいる講演のうち、主に海外向け活動で得られた知見について紹介したいと思います。これまで以下の海外カンファレンスで、モバイル/ウェアラブル関連の講演を実施してきました。古いものから新しいものへの順に、カンファレンス名(セッションタイトル)を列挙します。

  • Droidcon London (Developing Apps for Android on 2.x/3.x/4.x)
  • Droidcon Paris (Multi-Versioning Android Apps)
  • Droidcon Amsterdam (Developing Cross-Platform Apps)
  • Droidcon Madrid (Developing Android Wear Apps)
  • Droidcon Stockholm (Introduction to Android Wear)
  • DEVOXX Ukraine (Developing Cross-Platform Apps using Flutter)
  • DevFest Pisa (Best Practices and Tips
 in Flutter App Development)
  • Droidcon Kenya (Accelerate Flutter Apps Development)

自身にとっての初めての海外講演は、Droidcon Londonで2012年のことでした。こちらがそのときの様子です。(どうでもいいことですが、昔の自分を見ていると色々と懐かしいです?)

droidcon_uk_2012.jpg

その時期のAndroidアプリ開発は、複数のOSバージョン(2.x/3.x/4.x)が市場に混在することを考慮しなければならないという問題が顕在化し始めていました。また、Android搭載端末の画面サイズも多岐にわたるため、画面レイアウトが崩れることなく、適切に表示されるようにするには、発生しうる課題を意識した上で設計や実装を行わなければなりませんでした。みんな大好き(?)Fragmentが出てきたのもこの時ですね。

幸いなことに仕事やプライベートを含め、多くのアプリ開発を通じてノウハウを獲得できていたので、コンテンツ化し、まずは社内向けに研修や勉強会を通じて水平展開しました。余談ですが、かつてのAndroidと言えば、Platform Architectureの図や4大要素が最初に思いつく方は同じ時代を生きてきたんじゃないかなという予感がします。その当時、社外勉強会にも顔を出すようにしていました。さまざまなエンジニアの方々と話をしていると、自分も社会やコミュニティ、そして世界に対しても技術面で貢献したいという思いが高まっていきました。そこで社外勉強会や国内カンファレンスでの登壇を経て、海外講演にも挑戦していったという経緯があります?

その後は、Androidだけでなく、ウェアラブル関連、クロスプラットフォーム開発(Flutter)などに自分の得意分野を広げ、新しいテーマでも海外講演に取り組んでいます。これら活動で得られた知見をご紹介します。

海外カンファレンスへの参加・講演を通じて得られた気づきは、挙げるときりがないほどあるのですが、特に重要だと実感しているのが、 目的を意識した英語力 です。

目的を意識した英語力

初めての海外登壇が決まった時(2012年)に、最大の課題となったのが 英語力 です。この時点では当然、海外で発表した経験もなく(英語にはむしろ苦手意識を持っていました?)、mm月dd日に発表というゴールだけ決まっていました。その時は初めての経験ゆえ楽しみな気持ちはあったものの、正直に言えば、英語に関する不安の方が格段に大きかった記憶があります?

英語力といっても、 Reading(リーディング)Writing(ライティング)Listening(リスニング)Speaking(スピーキング) の4つの技能があります。それぞれ密に関係しているものの、個人的には、別スキルだと認識しています。言い換えると、仮にどれか1つが得意だからといって、他のもできるかというと全く別の話です(と少なくとも自分は考えています)。例えば、TOEIC L&Rが満点だとしても、英語を話せるかというとそうではないケースもありますよね。当時の自分は、Androidアプリの開発を行う過程で、英語のドキュメントを大量に読んでいたこともあり、リーディングは何とかなるかなあというレベルでした。が、その一方で、残りの3つ(Writing、Speaking、Listening)に関しては、どうしようという状況でした?

英語でプレゼンするときに、特に重要となるのが、Writing(スライド作成)とSpeaking(プレゼン)です(もちろん、プレゼン後の質疑応答にListeningも必要となるのですが、今回は割愛します)。当時を振り返りながら、どう乗り越えていったかを紹介します。

海外講演に向けて、途方にくれていても仕方ないので、まず発表スライドを英語で作っていくことにしました(Writing)。英語での技術プレゼンならではの慣習や表現など分からないことだらけだったので、 Speaker Deckslideshareなどにある技術系英語スライド を必死に見ました。気になる技術キーワードやカンファレンス名で検索することで、発表資料を見つけることができるはずです。これらのスライドを通じて、 導入、内容、結論に至るまでの流れや、スライド内の文字・図表の分量、画像の使い方などの慣習的なもの を感じ取ることができました(と言いつつ、特に明確なルールがある訳ではないので、先例を参考にしつつも最終的には自分が表現したいように作るのが良さそうです)。いまだに、「英語スライド作りを完璧にマスターした!?」とはほど遠い状態ですが、何とかスライドを作り上げることができるようになりました。登壇後に参加者と話をする中で、こういう表現の方がベターだったと学ぶことも多々あるので、「習うより慣れろ」を身を持って痛感しています。色んな気づきを与えてくれた、世界の皆さんに感謝の気持ちでいっぱいです。

次に、当日のプレゼンについてです(Speaking)。残念ながら、初めての海外講演時には、頭にあることをその場で英語で話すスキルはまるでありませんでした。そこで、「文字で書き起こせないことを当日話せるはずがない!」と悟り、話そうと思っている 全てのセリフを事前に考え、文字で書き起こしました ?。そして、スライドのノート欄にもメモとして入れる作戦を取りました(いわゆる、カンペですね?)

english_slide.png

最初はそのノート欄を見ながら、発表練習しました。ただ、実際にやってみると、 メモが目に入ると、視線がそこに集中してしまい、単純に文章を読み上げているだけの我ながらつまらないトークに なっているように感じました?そこで、 Google I/Oなどの技術カンファレンスの動画 を見ることで、一流の技術者がどのようにプレゼンしているのかを研究しました。すると、伝えようとする内容が、スライドの文字情報というよりは、表情や言葉、ジェスチャーなど、スピーカー本人からヒシヒシと伝わってきていることに気づき、ゾクゾクした記憶があります?

その後、見よう見まねで何度も練習して、メモをできるだけ見ずに、 自分の言葉で話すように 心がけました?そうは言いつつ、初めての海外講演を振り返ると、最終的に丸暗記した英語をただ話しただけのレベルに近かったのではと反省しています、、(が、何とか無事に終えることができました。)その後、英語での講演機会を重ね、参加者の皆さんに少しでも楽しんでもらい、何かを持ち帰ってもらえるように意識する余裕を持てるようになってきました。1つTipsで、 英語が母国語でない国で講演するときは、現地の言葉で少しだけでもいいので挨拶する と喜んでくれることが多い気がします。DEVOXX Ukraineのときは、現地スタッフに相談したら、一生懸命、ウクライナ語を教えてくださいました。ありがたいことです。↓がそのときのメモです。思いを伝えれば、きっと快く協力してくれるのではないかと思います!?

ua_memo.png

また、 デモは非常に効果的(ライブデモだと、さらにベター) という実感を持っています。セッションの後に、「もう1度デモを見せて欲しい。」「こういうことはできるの?」と聞かれたりするので、おすすめです!(↓はDevFest Pisa 2019のときの写真です。デモ中の写真がなかったのですが、当日はFlutterにおける画面作りの流れを実演しました)

devfest_pisa_2019.png

海外カンファレンスに参加すると、他国の人々と交流する機会もあります。そのときにおすすめなのが、 自分が作ったサービスやプロダクトがあれば、お互いに見せ合う というものです。自分はプライベートで、 セカイフォン(リアルタイム翻訳) というアプリを作っていることもあり、会話のきっかけの1つとして、毎回のように遊んでもらっています?もう1つ大切なのが、 自分から話しかける ことだと思います。自分も最初はどきどきでしたが、やってみると意外に通じるものです。こういうつながりはとても大切で、ある国で知り合った開発者と、別の国で再会したり、次の登壇のきっかけになることもあります。 「世界各地での出会い(点)がまさに線となってつながる」 感覚があります。2019年8月に、Droidcon Kenyaで登壇させていただく機会がありました。そのときは現地参加者に日本人は私1人という状況でしたが、お互いの取り組みを紹介していると、仲良くなることができました(↓がそのときの様子です)。 折れない心とコミュニケーションしたいという熱意があれば、なんとかなったりします(失敗して経験を積んでは次に活かすを繰り返していけば、成長できると考えています)

droidcon_ke_2019.jpg

ごちゃごちゃ書いてしまいましたが、 英語力の向上に大事なのは、 「自分のレベルはどれくらいで、どういうことを目的に、どのスキルをどれくらいのレベルになりたいか?」をしっかりと分析し、それに合った学習をすること だと実感しています。幸いなことに、英語を学ぶコンテンツは、無料/有料、書籍/オンラインコンテンツを含め、無数に存在しています。自分が以前、リスニング力を高めるために良い教材がないかと調べていると、某国の連続ドラマがとてもいいよという情報を見つけることができました。早速、実際に見たのですが、個人的には中身にまるで興味が沸きませんでした(楽しいと思えなかったので、即やめました?)逆に、訪れたことのない国の紹介や、開発者カンファレンスなどの動画は楽しいので、ワクワクしながら見ています?

このように、 目的や嗜好、レベルに合う学習教材は、人によってバラバラなので、他の人のおすすめとかにあまりこだわらず、自分の興味があるものを探してみるのがベスト だと思います。 仮に日本語版が存在したとしても見たいと思わないコンテンツは、英語版だとなおさら辛いだけではと思います。偉そうに書いてしまいましたが、そんな自分も海外を訪れたときにもっと話したり、交流したいという思いが猛烈に高まっていて、オンライン英会話に取り組む毎日だったりします、、、?

エンジニアという仕事について

エンジニアは、 「良くも悪くも一生学び続ける必要がある仕事」 だと実感しています。今回は、英語力にフォーカスして書きましたが、当然、技術力についても同様です。例えば、自身が専門としているモバイルの分野でも、その他の分野においても、取り巻くビジネス環境や、それを支える技術が日夜変化し続けています。アプリ開発1つを取っても少し前の開発方法では世間ではまるで通用しないといって過言ではない状況です。そんな状況ゆえ危機感も半端ないのですが、新しい技術に触れるやりがいや楽しさの方が大きいので、この仕事を選んでよかったなあと感じています。

世界中に、すごいエンジニアはたくさんいます。自分もそういった方々の背中を追いかけ、追いつくためには、常に自分を高め続けることが必須だと感じています。具体的には、 「インプットとアウトプットをバランスよく無限ループ?」 かなと思っています。どちらか1つだけをやっていてもうまく回らないなあという実感があるためです。今回、スペースの都合で触れることができませんでしたが、 エンジニアとして意識しておくとよいかもしれないこと は、会社ブログ記事(Androidで深まったエンジニアとしての歩み)で書いていますので、よかったらご覧ください。特に新しい技術が大好きなので、これからも新しいことにもチャレンジしていこうと考えています。

おわりに

今回は、エバンジェリストとして取り組んでいる海外講演活動から得られた気づきについて、ご紹介しました。このような活動に継続して取り組めているのも、会社や仲間など周囲の理解と協力があったからこそだと思います。いつもありがとうございます。そして、現地で出会ったたくさんの人たちにも感謝の気持ちでいっぱいです?今年のNTTテクノクロス Advent Calendarでは、Web/モバイルアプリ開発クラウドAI/機械学習ブロックチェーンPostgreSQLAWSサイバーセキュリティなどの専門家が記事を書いてまいりました。

改めて、NTTテクノクロス Advent Calendar 2019の記事一覧をご覧いただき、興味ある記事がありましたら、チェックいただけると幸いです。最後までお付き合いいただき、ありがとうございました!気が早い気もしますが、皆様、良いお年をお迎えください!

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

Androidアプリの開発するなら要件定義と設計はこう考えるといいんじゃない

はじめに

ごぶさたしてます。Javaおじさんです。
相変わらずのフリーランス稼業で糊口をしのいでいる感じですが、さすがにこのご時世Webアプリだけ作って食っていけるわけでもないのでAndroidのアプリにも手を出しているわけです。
ただ、Webアプリって結構設計前に考えなきゃいけないことが少ないせいで、Androidのアプリ考えるときいろいろスコーンと抜けてることが多くていざブツを作ろうとすると結構大変だったりします。
というわけで、いろいろ過去の事例を思い出しつつAndroidアプリを作る場合の要件定義や設計なんかについて備忘録代わりにちょっとまとめてみましょ。

前提

そもそもAndroidアプリだろうがiOSアプリだろうが、Webアプリと違って以下の特性が存在します。ぶっちゃけこのあたりはクライアント/サーバ型のシステムの設計/運用ノウハウが結構流用できると思うんだけど意外にもうみんな忘れてるんだろうか。

  • Webサーバと違ってメモリが決定的に少ない。なのであまり一度に大量のデータを受信するとメモリ不足でアプリが死ぬ。それゆえ事前のデータ量見積、各データのCRUDタイミングの洗い出し、インストール直後の初期データの定義はとても重要。
  • Webのサーバサイドと違ってデータをアドホックに取得するコストが高い。サーバサイドの感覚でモバイルの処理を設計するとAPIの粒度が小さすぎてコールバック地獄どころじゃないレベルになる。そのため、ある程度大きな粒度でサーバ側のAPIを設計する必要がある。あー、でも今だとRxJavaとかJetpackとかあるのでその辺使えるとそこまでコストは高くないのかも。...ホントに?
  • モバイルで運用するのが前提なのでモバイル機器ならではの考慮点が存在する。画面の回転やアプリの切り替えへの対応、バージョンアップの通知など。

なお、以下は別にAndroidに限ったことでもなく基本の話。

  • 開発初期にコードの良しあしを見極められるメンバーを確保するのがとても重要。内製するにせよ外部に委託するにせよ後から軌道修正するのは本当に本当に大変なので、最初からきちんとコードを律することのできる体制がとても重要。Javaでブツを作るのにパッケージ分割もロクにしてないとか、クラス間の相互直接依存関係をもりもり設定するとか、ネットのサンプルコードをコピペするしか能がないとかそんな奴は論外ですYO!
  • その意味では2019年の暮れにもなってMVVMとかJetpackとかのキーワードをぶつけてちゃんと理解した答えが返ってこないような要員は初期にチームに入れるべきではないと断言できます。開発後半になってアプリケーションアーキテクチャが定まったら別にいいけどね。
  • チーム開発するのに共有可能な概念モデルなしとかエスパーですかあんたたちは。「チームが自律的に動いてくれない」とお嘆きのそこのあなた、もしかしたら一般的な人間には無理なことを要求してるのかもしれないっすよ?情報は持ってる人間が出すのが基本です。だって他人の頭の中はふつう見えないからね?あんたの頭の中の情報は常にみんなに見えるようにしておかないとそりゃ自律的になんて動きようがないですからね?

要件定義ではこれを作っとこう

「要件定義は終わってます」と言いつつこのあたりが出てこなかったらその仕事は受けるか受けないか考えてしまうな。

採用するハードウェアのスペック表

上にも書きましたがモバイル機器はサーバどころか一般的なPCと比べてもリソース量は劣ります。...そのはず。極まれに支給されたPC見て「いやこれおれのiPhoneの方がマシだろ」とつっこみたくなる現場あるけど。

特にメモリは重要。ただし、「RAMが2GB」と言われても2GB使えると思っちゃいけません。OSがどこで動いてると思ってんの。他のアプリも動いてるんですよ。RAMが2GBと言われてもそのうち使えるのは半分もないと覚悟してソフトウェアの設計を考えるべき。

そういうことを考えないとサーバのDBのデータを一気に全件落としてくるとかいう愉快なステキ設計をします。その結果開発中は大丈夫でもいざ本番データ入れて実機検証始めるとメモリ不足でさくっと死ぬという事象が頻発します。Android Studioでログ見てるとすげえ勢いでGCログが流れたあとポクッと死ぬところが観察できてなかなか大笑い。いや笑えないけど。

データ量見積

ここでいうデータ量見積は以下の3種類。
いずれも重要です。

  • 初期データ量
  • データ増加量
  • データ変更量

初期データ量はわかりやすいですな。
稼働直後にDBテーブルそれぞれについて何行のデータが必要かを見積もります。
「商品」テーブルだったら扱ってる商品の数ってどのくらいですか?とかそういうヒアリングをちゃんとやること。
基本的に行数の情報とテーブル個々の設計があれば実際にどのくらいの容量(DBやらメモリやら)が必要になるかは概算でわかります。
1行100バイト程度のデータが10000行あるのと、画像データ含んだ1行1GB前後のデータが1000行あるのとどっちが重いかってことは考える必要があるけれども、普通のDBAがそんな1行1GBなんて設計するはずが...え?DB設計のスキルがない?なんなら追加料金払ってもらえば請け負いますけど?

データ増加量もわかりやすいと思うんですよ。
稼働開始から一定期間にどのくらいのペースでデータが増えていくのか見積もります。
上の商品の例でいえば、新しい取り扱い商品の増加ペースってどのくらいですかね?とかそんなヒアリングをすると。
これが結構激しい場合はローカルにデータをロードするのはちょっと考えた方がいいかも。
ローカルのマスターデータをサーバから差分でとってきて更新するとかいうことを考える場合、これと次に出てくるデータ変更量の見積もりが大事になります。

データ変更量は読んで字のごとく、登録されたデータがどれだけの頻度でどの範囲が更新されるかを見積もるものです。
商品情報の洗い替えを定期的にやったりするのかとか、データの削除タイミングとかから見積もることになります。
変更量が大きい場合には差分とか言いつつ結構なデータ量のダウンロードが必要になるので注意が必要ですね。これをちゃんと見積もっておくとおかないのとでは雲泥の差です。リスクがあるのかないのかわからない、というのがマネージメント上最悪だからね?

CRUD図

これも重要。
各種処理においてどのテーブルのデータをC(Create)、R(Read/Retrieve)、U(Update)、D(Delete)するかを表であらわしたもの。
上にも書いたけど、基本的にモバイルからサーバに対してデータを要求する処理はサーバサイドアプリに比べてはるかにコストが高いし使えるメモリも少ないので、処理を完結させるのに必要十分なデータを最初にまとめて持ってくる必要があります。なのでAPIの適切な粒度を見極めるためにはどの処理の中でどのデータがどれだけ必要なのかの情報が必要不可欠。
これがないと、必要もないデータを全部持っておこうとか逆にサーバサイドのWebアプリ作る感覚でAPIの粒度を小さくしてアドホックにデータを引っ張ろうとします。
どっちもモバイルアプリ向けの設計としちゃNGだと思うんだ。
特にAPIの粒度を細かくするのは本当にNG。コールバックの連鎖とかもう完全な地獄。ちょっと設計間違えるとリトライもしづらいようなややこしい構造が平気で出来上がるので、CRUD図は絶対的に必要。

概念モデル

説明不要ですよね?重要に決まってるよね?
そもそも概念モデルの共有もなしにどうやって設計すんの。
上述のCRUD図とか作る過程で当然のように作るでしょ?
ていうか作りなさい。

別にUMLとかでなくてもかまわないけど、あとからプロジェクト外の人間が入ってきたときに理解できる形式であることが大前提。少なくともExcelの表形式とかデータ間のつながりが全く分からないんで却下。
ER図でもいいんだけど視点がDBに寄りすぎるのはアプリケーションのアーキテクチャ考えるうえでちょっと引きずられてしまいがちなので注意するべき。

要件リスト

そりゃ要件定義なんだから作るよね?
顧客的にどれが優先事項か、そこもちゃんとヒアリングしてるよね?
技術的難易度による優先順位付けなんてもんはこっちで考えればいいんだから、要件定義で聞いてくるべきは「顧客が一番やりたいことはなんだ」ってことでいいです。
それがなければ「システム導入後の業務設計」もできないんだから。
顧客はシステムに夢を見るべきだし、おれらはそれを技術でかなえるのが仕事なんです。
ただし、空を飛べとか言われても無理なんでそこは技術的な検討後にきちんとできないものは「xxなんでできないです、代わりにこんなんどうですか」って言わないといけないけれども。

なに、こんなにいろいろ作るのめんどくさい?
じゃあやめちまえよもう。
プロなんでしょ?ギャラもらってるんでしょ?もらったギャラ分は働きなさいよ。

設計するとき気を付けること

これはサーバサイドアプリじゃないと強く意識すること

まずはこれに尽きるんじゃないかなと。
前提としてモバイル側でやれることは限定されているので、サーバ連携をすること前提なら多量のデータを扱うタスクはサーバ側でやらせること。
逆にモバイル側でないと拾えないデータについてはモバイル側で処理をする、といったようにデータのCRUDを考えてどちらで処理するかをきちんと意識することがとても重要になります。
なに?CRUD表作ってない?...お疲れ様でした。終了~。

Excel方眼紙なんて窓から投げ捨てること(特に画面設計をするときは)

画面設計をExcel方眼紙でやっている時間ほど無駄な時間は、他に類を見ない。
エビデンスたらいうスクショコピペを作ってる時間よりも無駄。あ、どっちもExcelだった。
と言いつつおれは別にExcelは嫌いじゃないですよ?何の生産性もない無駄な作業が嫌いなだけ。

だって考えてみ?Excel方眼紙でしこしこ画面設計作りました、レビューとおりました、そのあとどうつながるの?何にもつながらないよね?そのExcel方眼紙がレイアウトファイルになったりするの?なんないでしょ。
それどころかモバイルの特性無視した激重UIを簡単に生むよね?
ListViewやらRecyclerViewの1行に20個くらいTextView載せたりとかさ。しかもそれが結構な行数出てくるとかさ。そら動きももっさりするわ。
「頑張ってデータ詰め込みました」...いやそれWebだったらただの文字列で済むけどモバイルだと回転したときの再配置とかを考慮しないといけないから文字表示するにもそこそこリソース使うのよ?
そんなところ頑張らなくていいですから。

だったら最初っからAndroid Studioでレイアウトファイル作ってそれエミュで動かしてお客さんにみてもらった方が早いんですよ。イメージもつけやすい。アジャイルとかそういう以前の問題として、これから何を作るか確認する、というのは必要だしそれを最もイメージしやすいのは実機で動くモックですよ。
あんまり詳しくないのであれですが、プロトタイプツールだとレイアウトファイルに変換できるものもあるかもしれないのでそっちはAndroid Studioより使い勝手が良ければ使ってもいいのかもしんない。

だがExcel方眼紙、てめーだけはだめだ。...や、おれが知らないだけでF通とかがExcelをレイアウトファイルに変換するようなツール作ってるのかもしれんけど。

画面設計とDB設計は別物と考える

案外画面項目=DBカラムみたいに考えてしまいがちなのでそこは注意するべき。
昔よく見たなーその手の設計。そのたびひどい目にあったっけ。
画面項目の変更に合わせてDB項目が毎日毎日朝令暮改でくるくる変わるとかもう二度とごめんです。

画面はあくまでViewであり、本質はドメインデータであることを忘れてはいけないし、ドメインデータがRDBにそのままマッピングできるものでもないというのも忘れてはいけない。
RDBはあくまでデータの永続化の一形態に過ぎないのでER図が必ずしも正とは言いきれないのですよ。
その辺クリーンアーキテクチャとかDDDとか参考にするべき話はいっぱい転がっているので勉強しないとなー(自戒)。

とりあえずデータに関しては、たとえばモバイルで集めたデータをどんな風に今後活用するのか、という視点まで含めて考えたいところ。
モバイルでデータを集めるというのはこれは容易にビッグデータになりうるので、それを分析して以後のビジネスにつなげるというのはちょっと面白そうだし挑戦してみる価値はある。
そういった未来図を描くのも仕事のうちなのだから、そのためにどんなデータ設計をすればいいのかを学ぶのは意味がある。とはいえここら辺はデータサイエンティストの領分に近づいていくわけだけど。

贅沢は敵

富豪的なプログラミングはできないものと覚悟してください。
だから言ってるでしょリソース少ないんだって。
作ったそばからインスタンスをGCに放り込むような真似は今すぐやめるんだ。

MVVMを実現するための技術は積極的に使う

Databindを使うだけでもかなりいろいろと楽になります。
むしろ使わない奴がドMに見えるレベル。
「開発が楽になる=保守が楽になる」なので、後の世のためにも積極的に楽をしていきたい所存。

おしまい

とりあえず今んとこ言えるのはこんなところかなーと。
とにかく開発初期にきちんといろいろ整えておくことで、バグの発生やら手戻りやらを抑えられるので最初にパワーをかけるべきだし、いっそ要件定義と同時に受入テストのテストケースだって書いてしまった方がいいと思います。
後回しにしたっていいことなんて何もないしね。

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

Google Issue Trackerにライブラリの不具合報告した話

この記事は、Android#2のアドベントカレンダーの20日目の記事になります。

初めてGoogle Issue Trackerに不具合報告した話になります

経緯

ViewPager2のβ版を使って色々試していた際に謎挙動に遭遇。
ググっていろいろ調べた結果解決策がなさそうだということが判明しました。

そういえばIssue Tracker みたいなのあったなーと思い出し、
そこで不具合報告されてないかと調べてみましたが、
残念ながらそれらしい報告はありませんでした。

それなら自分で報告してしまおうということでIssue TrackerでIssueを作成することにしました。

(ちなみに遭遇した不具合は
CoordinatorLayoutScrollViewを内包したViewPager2を使った場合ViewPager2の横スクロールの挙動がおかしくなる
というものでした)

Issue作成に必要無なもの

ひとまず Create Issue ボタンを押し必要な項目を確認しました

SnapCrab_NoName_2019-12-20_23-59-12_No-00.png

必要なのは以下の3つなのがわかりました

  • Component
    • 今回はViewPager2についての不具合報告なのでとりあえずViewPager2と入力します
    • そうすると補完候補にViewPager2のComponentが出てくるのでそれを選択。
  • Title
  • Description

最初の難関

最初の難関。それは英語でした。

恥ずかしながら私は英語が苦手です。
プログラムのエラー文程度ならなんとか読めますし、分からなければGoogle翻訳などを使えばなんとかなります。
ただ、これは英語を読む場合の話。

書く場合はどうしたら良いのだろうと一瞬悩みましたが、とりあえずGoogle翻訳に頼ることにしました.

箇条書きで不具合の発生条件と内容を日本語で書き出し、それをGoogle翻訳で翻訳し、その後手直しする という方法で行くことにしました。

Issueタイトル

本文はできましたがタイトルがまだできていません。
他のIssueのタイトルを見てみると先頭に[ViewPager2]と書いてあるものが多かったのでそれに倣うことにしました。

Issue作成とその後

ここまでの流れの結果できあがったのが以下のIssueです。

(Issue作ったあとで謎挙動のあったバージョンを書き忘れているのに気づき、慌ててコメントを書いています。)

半日程度でリアクションがありました

More information on this in:
http://issuetracker.google.com/124042228#comment3
http://issuetracker.google.com/124042228#comment7

他のIssueのコメントに似たような報告があるようでそっち見てくれ とのこと

まじかよ。すでに報告あったのかよ。しかもコメントかよ。
など思いながら内容を確認し 「多分そのコメントと同じ問題だと思うよ(震え声)」 とコメントを返しました

そして次の日

SnapCrab_NoName_2019-12-21_0-29-25_No-00.png

ん?ステータスがAssignedになった!?
これは対応してくれるということだろうか?
(初めてなのでよくわからない)

そこから6日後

下のコメントにあったサンプル調査した旨のコメントが来ました。更に調査するとのこと

http://issuetracker.google.com/124042228#comment7

次の日

SnapCrab_NoName_2019-12-21_0-38-53_No-00.png

あれ?担当者変わった?
ステータスもAcceptedに変わった。調査担当と修正担当は別ということか?
(初めてなのでどういうことかよくわかってない)

そこから8日後

突然コミットログのようなコメントが来ました。
修正が終わってブランチにコミットされた?ようです。

今回の不具合はViewPager2の不具合というよりその内部で使用しているRecyclerViewに原因があったようです。

そして・・・

SnapCrab_NoName_2019-12-21_0-53-12_No-00.png

修正が終わり、当時のrecyclerviewの次期バージョン1.1.0-beta04 に反映されるとのこと。
ステータスもfixedに変わっています。

実際にリリースされたときのリリースノートが以下
https://developer.android.com/jetpack/androidx/releases/recyclerview#1.1.0-beta04

Fixed a bug where RecyclerView was not disallowing touch intercept when nested pre-scrolling caused a NestedScrollingParent to scroll (b/138668210, aosp/1105373). This benefits libraries such as ViewPager2.

b/138668210が私が報告したIssueですね。
(こういう場所に報告したissueが載るのはなんだか嬉しいものですね)

ということで修正版がリリースされたことが確認できました。

また、肝心のViewPager2ですが、1.0.0-beta04でRecyclerViewの修正バージョンに依存が更新されたことが確認できました

https://developer.android.com/jetpack/androidx/releases/viewpager2#1.0.0-beta04

A number of issues were fixed in other components to work better with ViewPager2: RecyclerView, NestedScrollView, and Navigation.

まとめ

Issue報告ってそんなに難しくなかったよ ということが伝わればいいなと思いこの記事を書きました。
実際、こんなにも拙い英語でも意味は伝わっていたようなので
不具合の内容にもよりますが英語もそこまで問題にはならないかと思います。

今回Issueの作成から修正版のリリースまで1ヶ月と少し程度でした。
これが早いほうなのか遅いほうなのかわかりませんが、参考までに。

1点心残りは自分でもサンプル作って公開してればもっと早く調査とか終わったのかな?という点です。
次回があればサンプルも用意しよう。

以上

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