20201223のAndroidに関する記事は15件です。

Jetpack Composeを使った学内アプリのレイアウト作成

前置き

FUN Advent Calendar 2020 Part1、24日目の記事です。

何か技術的な記事を書こうかなと思っていて、一応修士1年だし研究やら就活やら色々やることがあったので、去年のアドカレで扱っていたJetpack Composeを使って何かを試してみようという方針になりました。

概要

本記事では、少しは学内受けしそうな内容と弊学の高度ICT演習の宣伝(?)も兼ねて、自分自身が参加しているはこだてSweetsのAndroidアプリのレイアウト作成をJetpack Composeを使ってやってみようと思います。コード公開すると問題ありそうなので、レイアウトのみです。

ちなみに、去年のアドカレでも扱っていたJetpack Composeですが、2020年8月27日にようやくアルファ版がリリースされました???
2020年12月22日現在、最新バージョンは1.0.0-alpha09になっています。

Android Studioバージョン

一応、この記事を書いた段階でのAndroid Studioバージョンを記しておきます。
Android Studio Arctic Fox(2020.3.1) Canary 2

※Canary版じゃないとJetpack Composeを試すことができないわけではありません。ある程度の制約・条件はあるものの、各自お使いのAndroid Studioでも試すことができます。

本題

概要に書いたとおり、はこだてSweetsのAndroidアプリのレイアウトを作成します。
全画面の実装は時間的に厳しかったので、メイン画面のみのレイアウトにしました。
本当は、Playストアの画像とか貼れれば良かったのですが、諸々の事情によりリリースできていないので、下記に貼った画像を元にレイアウトを作っていきます。(リリースできていたら、リンク貼ったのに..)本文中では変数や引数ファイルや関数、componentという表記で区別しています。

使用ライブラリ

レイアウトの作成に関係のあるライブラリ記述部分が以下になります。

build.gradle(app)
def compose_version = "1.0.0-alpha09"
def coil_version = "0.4.0"

implementation "androidx.compose.foundation:foundation:$compose_version"
implementation "androidx.compose.foundation:foundation-layout:$compose_version"
implementation "androidx.compose.runtime:runtime:$compose_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
implementation "androidx.compose.animation:animation:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"

implementation "dev.chrisbanes.accompanist:accompanist-coil:$coil_version"

各ライブラリのバージョンは本記事を書いた時点での最新バージョンになっています。
最新バージョンについてきになる方は、以下からご確認ください。

  • compose
  • accompanist
    • Jetpack Compose上でURLなどから画像を表示させるライブラリで、Coil/Glide/Picassoの各種ライブラリが用意されている

レイアウト作成

以前の記事でも書いていた通り、Jetpack ComposeではsetContent内にレイアウトを書いていきます。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            SweetsApp(repository)
        }
    }
}

今回は、SweetsAppという関数内にレイアウトを構築していきます。引数のrepositoryは、APIやRoom周りの処理を管理しているクラスインスタンスなので、その中身等は省略しています。

SweetsApp

SweetsAppは、作成したアプリ内で表示させる大元の画面になっています。

SweetsApp.kt
@Composable
fun SweetsApp(
    repository: Repository
) {
    var currentBottomItem by remember { mutableStateOf(Screen.SWEETS.name)  }
    val screenWidth = AmbientConfiguration.current.screenWidthDp.dp

    SweetsApp_JetpackComposeTheme {
        Surface(color = MaterialTheme.colors.background) {
            val screenWidth = AmbientConfiguration.current.screenWidthDp.dp
            Scaffold(
                topBar = {
                    when(currentBottomItem) {
                        Screen.SWEETS.name -> SweetsAppBar(
                                icons = listOf(
                                    Icons.Outlined.Info, 
                                    Icons.Outlined.Search
                                )
                        )
                        else -> SweetsAppBar()
                    }
                },
                bodyContent = {
                    SweetsAppContent(
                        modifier = Modifier
                            .padding(bottom = BottomNavigationHeight),
                        repository = repository,
                        screenWidth = screenWidth,
                        currentBottomItem = currentBottomItem
                    )
                },

                bottomBar = {
                    SweetsAppBottomNavigation(
                        bottomNavigationButtons = listOf(
                            BottomNavigationButton(
                                vectorResource(id = R.drawable.ic_cake),
                                "スイーツ",
                                Screen.SWEETS.name
                            ),
                            BottomNavigationButton(
                                Icons.Outlined.Place,
                                "マップ",
                                Screen.MAP.name),
                            BottomNavigationButton(
                                Icons.Outlined.FavoriteBorder,
                                "お気に入り",
                                Screen.FAVORITE.name),
                            BottomNavigationButton(
                                vectorResource(id = R.drawable.ic_coupon),
                                "クーポン",
                                Screen.COUPON.name
                            )
                        ),
                        currentBottomItem = currentBottomItem,
                        onClickBottomNavigation = { selectBottomItem ->
                            currentBottomItem = selectBottomItem 
                        }
                    )
                }
            )
        }
    }
}

ここでは、currentBottomItemscreenWidthの変数宣言とScaffoldの呼び出しをしています。

currentBottomItem

currentBottomItemは、Bottom Navigationの選択されている場所を管理する変数になっています。rememberを使用することによって、currentBottomItemの値が更新されると、currentBottomItemを使用する部分のレイアウト更新が行われます。これを利用することによって、Bottom Navigationによる画面の切り替えを行っています。

screenWidth

screenWidthは、端末の画面幅を取得した値を保持しています。これを利用し、Grid表示の画像サイズ等を設定しています。

Scaffold

作成したアプリでは、Scaffoldを使用し各種レイアウト用関数を呼び出しています。
Scaffoldは、引数を渡すことによって、アプリ開発の基本的なレイアウト構築してくれます。作成したアプリでは、topBarbodyContentbottomBarを引数に渡しています。
topBarには、SweetsAppBarbodyContentにはSweetsContentbottomBarにはSweetwAppBottomNavigationを引数に渡しています。各引数については、各章にて説明するため、ここでは省略します。

Scaffoldにはそのほかにも引数として渡すことができる(FAB等)ので、気になる方は調べてみてください。

注意

後述するのですが、ScaffoldtopBarbodyContentbottomBarを引数に渡すことによって、topBarbodyContentの配置位置はちょうど良い感じになります(topBarの高さ分がbodyContentのTop Paddingに追加される)。しかし、bodyContentbottomBarの配置位置は良い感じにならず、bottomBarの裏側までbodyContentが表示されてしまいます。

SweetsAppBar

SweetsAppBarでは、TopAppBarを利用しApp barの実装をします。TopAppBarを利用することによって、従来のサイズと同様のAppBarを実装できます。

引数の説明
modifier
親のレイアウト設定(Padding等)を引き継ぐ用

headerLogo
AppBarの左側に表示するheader画像

onHeaderLogoPressed
headerLogoをクリックした際の処理

icons
App Barの右側に表示するアクションiconリスト

SweetsAppBar.kt
@Composable
fun SweetsAppBar(
    modifier: Modifier = Modifier,
    headerLogo: Int = R.drawable.header,
    onHeaderLogoPressed: () -> Unit = {},
    icons: List<ImageVector> = listOf()
) {
    TopAppBar(
        modifier = modifier,
        backgroundColor = primaryColor,
        elevation = 5.dp,
        contentColor = MaterialTheme.colors.onSurface
    ) {
        Image(
            imageVector = vectorResource(id = headerLogo),
            modifier = Modifier
                .padding(start = 12.dp)
                .clickable(onClick = onHeaderLogoPressed)
                .align(Alignment.CenterVertically)
        )

        Row(
            modifier = modifier.align(Alignment.CenterVertically)
        ) {
            for (icon in icons) {
                IconButton(
                    onClick = {  }
                ) {
                    Icon(
                        imageVector = icon,
                        tint = Color.White

                    )
                }
            }
        }
    }
}

作成するアプリのApp Barでは、header画像とアクションiconを表示しています。
header画像はImageを使用し、imageVectorにheader画像を渡すことによって表示しています。
アクションiconはRowIconButtonを使用し、表示しています。
Rowを使用することによって、子レイアウトを水平方向に配置できるので、受け取ったiconsの要素分だけ子レイアウトにIconButtonを追加することによって実装しています。また、IconButtonにonClickを追加しクリック時の処理を追加したり、子レイアウトにIconを追加しiconを表示しています。

SweetsContent

SweetsContentでは、App BarとBottom Navigationの間に表示するレイアウトを実装します。

引数の説明
※子レイアウトに渡すだけの変数、前述した変数については説明していません
screenWidth
端末の画面幅

currentBottomItem
Bottom Navigationの選択されている画面名

SweetsContent.kt
@Composable
fun SweetsAppContent(
    modifier: Modifier = Modifier,
    repository: Repository,
    screenWidth: Dp,
    currentBottomItem: String
) {
    val sweetsListScrollState = rememberScrollState(0f)

    when(currentBottomItem) {
        Screen.SWEETS.name -> SweetsScreen(
            repository = repository,
            modifier = modifier,
            screenWidth = screenWidth,
            scroll = sweetsListScrollState
        )

        Screen.MAP.name -> MapScreen(
            repository = repository,
            modifier = modifier
        )

        Screen.FAVORITE.name -> FavoriteScreen(
            repository = repository,
            modifier = modifier
        )

        Screen.COUPON.name -> CouponScreen(
            repository = repository,
            modifier = modifier
        )
    }
}

ここでは、選択中の画面名currentBottomItemによって、表示する画面の切り替えを行っています。本記事では、SweetsScreenのみ後述します。

sweetsListScrollState

SweetsScreenの子レイアウトにてScrollableColumnを使用するのですが、画面を切り替え、戻った際に元のスクロール状態を保持しておく必要があります。そこで、rememberScrollState()を使用し、sweetsListScrollStateにてスクロール状態を管理しています。使い方については、SweetsScreenにて記述します。

SweetsScreen

ViewModel内の処理を行い、表示させるスイーツを取得、それを用いて画面への表示を行うようにします。

引数の説明
※子レイアウトに渡すだけの変数、前述した変数については説明していません
repository
APIやRoom周りの処理を行うクラスインスタンス

scroll
スクロール状態の保持

SweetsScreen.kt
@Composable
fun SweetsScreen(
    repository: Repository,
    modifier: Modifier,
    screenWidth: Dp,
    scroll: ScrollState
) {
    val viewModel: SweetsViewModel = viewModel(factory = SweetsViewModel.Factory(repository))
    val sweetsItemList: List<SweetsListItem> by viewModel.sweetsItemList.observeAsState(listOf())

    viewModel.getSweetsItemList()

    SweetsScrollableColumn(
        modifier = modifier,
        scroll = scroll,
        sweetsItemList = sweetsItemList,
        screenWidth = screenWidth,
        onItemClicked = viewModel::onItemClicked

    )
}

ここでは、まずSweetsViewModelのインスタンスを生成しています。
そして、viewModel.getSweetsItemList()を実行し、リストに表示させるスイーツ情報を取得、SweetsViewModel内のLivedata型であるsweetsItemListに保存しています。その変更をobserveAsState()を用いて監視し、その変数をSweetsScrollableColumn()に渡すことによって、画面にリストを表示しています。

SweetsScrollableColumn

スイーツリストを表示させ、そのリストをスクロールできるレイアウトを実装します。

引数の説明
※子レイアウトに渡すだけの変数、前述した変数については説明していません
scroll
スクロール状態の管理用

screenWidth
端末画面のwidth

@Composable
fun SweetsScrollableColumn(
    modifier: Modifier = Modifier,
    scroll: ScrollState = rememberScrollState(0f),
    sweetsItemList: List<SweetsListItem>,
    screenWidth: Dp,
    onItemClicked: (SweetsListItem) -> Unit = {}
) {
    ScrollableColumn(
        modifier = modifier,
        scrollState = scroll
    ) {
        SweetsGrid(
            modifier = Modifier,
            sweetsItemList = sweetsItemList,
            layoutWidth = screenWidth / 2,
            elevation = 5.dp,
            onItemClicked = onItemClicked
        )
    }
}

ここでは、レイアウトの垂直方向への配置を行っています。
Columnを使用することによって、垂直方向への配置を行うことができますが、スクロールを行うことができません。そこで、スクロールが可能で垂直方向への配置を行うScrollableColumnを使用します。このScrollableColumn内に、SweetsGridを実装することによって、SweetsGridの高さ分だけスクロールが可能になります。また、ScrollableColumnscrollStateに、親レイアウトで作成したscrollを設定することによって、画面を切り替え戻った場合でも、スクロール位置を保持することができます。

SweetsGrid

スイーツ情報をGrid上に表示するレイアウトを実装します。

引数の説明
※子レイアウトに渡すだけの変数、前述した変数については説明していません
sweetsItemList
Grid表示するスイーツ情報(表品名、店舗名、画像URL)のリスト

rowSize
表示するGridの列数

@Composable
fun SweetsGrid(
    modifier: Modifier = Modifier,
    sweetsItemList: List<SweetsListItem>,
    layoutWidth: Dp,
    elevation: Dp,
    rowSize: Int = 2,
    onItemClicked: (SweetsListItem) -> Unit = {}
    ) {

    val listChunked = sweetsItemList.chunked(rowSize)

    for (sweetsRowList in listChunked) {
        Row(modifier = modifier) {
            for (sweets in sweetsRowList) {
                SweetsGridItem(
                    sweets = sweets,
                    layoutWidth = layoutWidth,
                    elevation = elevation,
                    onItemClicked = onItemClicked
                )
            }
        }
    }
}

SweetsScrollableColumnにて、垂直方向にレイアウトを配置させるようにしたので、ここでは水平方向にレイアウトを配置させています。
まず、chunked()を使用します。chunked実行例は以下のようになります。

val words = "one two three four five six seven eight nine ten".split(' ')
val chunks = words.chunked(3)
println(chunks) // [[one, two, three], [four, five, six], [seven, eight, nine], [ten]]

chunked()を使用し、sweetsItemListrowSizeずつのリストに分けることによって、listChunked = [[SweetsListItem(..), SweetsListItem(..)], [SweetsListItem(..), SweetsListItem(..)]]のように各行で表示させるスイーツ情報リストができます。そのためこのlistChunkedを使い、1行ごとにSweetsGridItemRow内に実装することによって、水平方向へのレイアウト配置をしています。

SweetsGridItem

表示させるスイーツリストの1要素のレイアウトを作成します。

引数の説明
※前述した変数については説明していません
sweets
Grid表示するスイーツ情報(表品名、店舗名、画像URL)

layoutWidth
作成する要素のWidth

elevation
設定する影のサイズ

onItemClicked
要素をクリックした時の処理

@Composable
fun SweetsGridItem(
    modifier: Modifier = Modifier,
    sweets: SweetsListItem,
    layoutWidth: Dp,
    elevation: Dp,
    onItemClicked: (SweetsListItem) -> Unit = {}
) {
    Column(
        modifier.background(Color.White)
            .width(layoutWidth)
            .padding(8.dp)
            .clickable(onClick = { onItemClicked(sweets) }),
    ) {
       Card(
           shape = shapes.large,
           elevation = elevation
       ) {
           CoilImage(
               data = sweets.imagePath,
               contentScale = ContentScale.Crop,
               modifier = modifier.size(layoutWidth - elevation*2)
           )
       }

        Text(
            modifier = modifier.padding(
                top = 4.dp,
                start = 8.dp,
                end = 8.dp
            ),
            text = sweets.sweetsName,
            fontSize = 12.sp,
            color = Color.Black,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )

        Text(
            modifier = modifier.padding(
                horizontal = 8.dp
            ),
            text = sweets.shopsName,
            fontSize = 10.sp,
            color = Color.LightGray,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}

ここでは、1要素のレイアウトを作成しています。
作成するアプリ画像より、商品画像・商品名・店舗名が垂直方向に配置されているので、Columnの中でレイアウトを実装しています。商品画像については、Cardに影を付け、その中でCoilImageを使用し表示しています。CoilImageは、dataに画像URLを渡すだけで画像を表示してくれます(Coil等が対応していないため、すごく助かりました)。CoilImageは、Jetpack Composeではないためこちらから詳細をご確認ください。
商品名・店舗名については、Textを使用しtextに表示するテキストを設定しています。また、fontSizeでテキストのサイズ変更、colorでテキストの色変更、maxLinesで最大行数の設定、overflowで文字数が超過する場合の省略設定をしています。

SweetwAppBottomNavigation

SweetwAppBottomNavigationでは、Bottom Navigation内に表示するレイアウトを実装します。

引数の説明
※子レイアウトに渡すだけの変数、前述した変数については説明していません
bottomNavigationButtons
Bottom Navigationに表示する、ボタン情報(icon、表示テキスト、表示する画面名)のリストを受け取る用

SweetwAppBottomNavigation.kt
@Composable
fun SweetsAppBottomNavigation(
    modifier: Modifier = Modifier,
    bottomNavigationButtons: List<BottomNavigationButton> = listOf(),
    currentBottomItem: String,
    onClickBottomNavigation: (String) -> Unit = {}
) {
    BottomNavigation(
        modifier = modifier,
        backgroundColor = Color.White,
        content = {
            for (bottomNavigationButton in bottomNavigationButtons) {
                val tint = if (bottomNavigationButton.name == currentBottomItem) {
                    primaryColor
                } else {
                    Color.Gray
                }
                SweetsBottomNavigationItem(
                    modifier = modifier,
                    bottomNavigationButton = bottomNavigationButton,
                    tint = tint,
                    onClickBottomNavigation = onClickBottomNavigation
                )
            }
        }
    )
}

作成したアプリでは、BottomNavigationを使用し、このBottomNavigationcontentにレイアウトを実装し、Bottom Navigationを表示しています。contentには、受け取ったbottomNavigationButtonsの要素分だけ、SweetsBottomNavigationItemを追加しています。ただし、表示されている画面の要素は色を変える必要があるので、bottomNavigationButton.namecurrentBottomItemを比較し、SweetsBottomNavigationItemを追加する際に引数として渡す色を変えることによって実装しています。

SweetsBottomNavigationItem

Bottom Navigationの1要素のレイアウトを実装します

引数の説明
※前述した変数については説明していません
bottomNavigationButton
Bottom Navigationに表示する、ボタン情報(icon、表示テキスト、表示する画面名)を受け取る用

tint
要素(Icon、Text)に設定する色情報

onClickBottomNavigation
Bottom Navigationが新たに押された時の処理で戻り値として、画面名を返却

@Composable
fun SweetsBottomNavigationItem(
    modifier: Modifier = Modifier,
    bottomNavigationButton: BottomNavigationButton,
    tint: Color = Color.Gray,
    onClickBottomNavigation: (String) -> Unit = {}
){
    BottomNavigationItem(
        icon = {
            Column(
                modifier = modifier
            ) {
                Icon(
                    imageVector = bottomNavigationButton.icon,
                    tint = tint,
                    modifier = modifier
                        .align(Alignment.CenterHorizontally)
                        .size(24.dp)
                )
                Text(
                    text = bottomNavigationButton.text,
                    color = tint,
                    modifier = modifier.align(Alignment.CenterHorizontally),
                    fontSize = 14.sp
                )
            }
        },
        selected = true,
        onClick = {
            onClickBottomNavigation(bottomNavigationButton.name)
        }
    )
}

SweetsBottomNavigationItemでは、BottomNavigationItemを使用し各要素のレイアウトを実装しています。BottomNavigationItemを使用することによって、従来と同様のリップルが表示されるようになります。このBottomNavigationItemicon内に各要素を実装します。作成するアプリの各要素では、アイコンの下にテキストを表示させています。そのため、子レイアウトを垂直方向に配置できるColumnを使用し、子レイアウトに受け取ったicon画像を表示させるIconと受け取ったiconテキストを表示させるTextを実装しています。その際、IconText受け取ったtintを設定しています。
BottomNavigationItemonClick内で、画面名(bottomNavigationButton.name)を引数にonClickBottomNavigationを実行し、SweetsApp内のcurrentBottomItemが更新されることによって、Bottom Navigationによる画面の切り替えを実装しています。

完成

ここまでの実装で、下記画像のようなレイアウトを作ることができます。(もとのアプリ画像と多少違うところは見逃してください)多少の違いはあるものの、最低限レイアウト自体はできたと思っています。

多少の違いはあるものの、最低限レイアウト自体はできたと思っています。本記事では、MapScreenなどに触れていないのですが、各関数を作成さえすれば、Bottom Navigationで画面切り替えを試すことができるかなと思います。今回は基本的にクリック時の処理を書いていないので、実際にはその辺りの処理を書く必要が出てくるかなと思います。

まとめ

本記事では、はこだてSweetsのAndroidアプリのレイアウトを作成しました。やってみると自由度が高い分、好きに実装できるので実装していてとても楽しかったです。自分は以前SwiftUIに触れていた時期があったので、考え方などがほぼ同じだったりするので、その時の知識が活きた場面が多々ありました。なので、SwiftUIとJetpack Composeのどちらかさえ触っていれば、もう片方を触れるときにかなり実装がスムーズになるのではと思いました。

はこだてSweetsのAndroidアプリでは、直近でJetpack Composeを使用して開発する話は出てきていないので、今回は完全に個人で試してみたって感じになっています。どこかで、がっつりJetpack Compose使ったプロダクトとか参加してみたい気持ちになれました。

そういえば、明日がFUN Advent Calendar 2020 Part1の最終日です。明日は、はまですね。きっとトリにふさわしい内容を書いてくれるはず。では、最終日もお楽しみに!

参考サイト

Jetpack Compose Samples:https://github.com/android/compose-samples
State and Jetpack Compose:https://developer.android.com/jetpack/compose/state#viewmodel-and-jetpack-compose

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

AndroidのUIテストで、非同期処理が完了するまで待機する(Coroutine編)

AndroidのUIテストの話。Espresso前提。

非同期処理にCoroutineを使っている場合に、sleepなしで処理完了まで待機する方法を説明する。

Rxと違ってプロダクトコードの修正も必要。テスト実行時は、DispatchersのIO/Defaultをテスト用のものにすり替えられるようにする1。そして、テスト実行時のDispatchersとIdlingResourceと紐付ける。

Step1. CoroutineのDispatchersをすり替えられるようにする

以下のようなクラスを作る。元ネタ。

CoroutinePlugin.kt
object CoroutinePlugin {

    private val defaultIoDispatcher: CoroutineContext = Dispatchers.IO
    val ioDispatcher: CoroutineContext
        get() = ioDispatcherHandler?.invoke(
                defaultIoDispatcher
        ) ?: defaultIoDispatcher

    @set:VisibleForTesting
    var ioDispatcherHandler: ((CoroutineContext) -> CoroutineContext)? = null

    private val defaultComputationDispatcher: CoroutineContext = Dispatchers.Default
    val defaultDispatcher: CoroutineContext
        get() = computationDispatcherHandler?.invoke(
                defaultComputationDispatcher
        )
                ?: defaultComputationDispatcher

    @set:VisibleForTesting
    var computationDispatcherHandler: ((CoroutineContext) -> CoroutineContext)? = null

    private val defaultMainDispatcher: CoroutineContext = Dispatchers.Main
    val mainDispatcher: CoroutineContext
        get() = mainDispatcherHandler?.invoke(
                defaultMainDispatcher
        ) ?: defaultMainDispatcher

    @set:VisibleForTesting
    var mainDispatcherHandler: ((CoroutineContext) -> CoroutineContext)? = null

    @VisibleForTesting
    @JvmStatic
    fun reset() {
        ioDispatcherHandler = null
        computationDispatcherHandler = null
        mainDispatcherHandler = null
    }
}

プロダクトコードからは、Dispatchers.IOやDefaultを直接使うのではなく、作ったCoroutinePlugin#ioDispatcherやdefaultDispatcherを参照する。

// withContext(Dispatchers.IO) { ... }
withContext(CoroutinePlugin.ioDispatcher) { ... }

Step2. Coroutine処理待機用のTestRuleを作る

(Kotlin 1.4向けの記述なので、1.3系だとコンパイルエラーになる)

class CoroutineTestRule : TestRule {
    override fun apply(base: Statement, description: Description?): Statement = object : Statement() {
        override fun evaluate() {
            // IdlingThreadPoolExecutorないときはbuild.gradleに以下を記述する。
            // androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:3.3.0"
            val executor = IdlingThreadPoolExecutor(
                    "Coroutine",
                    5,
                    10,
                    5L,
                    TimeUnit.SECONDS,
                    LinkedBlockingQueue()
            ) { r -> Thread(r) }

            CoroutinePlugin.ioDispatcherHandler =  { executor.asCoroutineDispatcher() }
            CoroutinePlugin.computationDispatcherHandler =  { executor.asCoroutineDispatcher() }

            try {
                base.evaluate() // この中でBefore/Test/Afterの処理が実行される
            } finally {
                CoroutinePlugin.reset()
                executor.shutdownNow()
            }
        }
    }
}
  • IdlingThreadPoolExecutorを作る
    • EspressoのIdlingResourceとJavaのExecutorServiceの両方を実装しているクラス
    • IdlingRegistryのregister/unrgesiterは、内部で実行してくれる
  • executorからCoroutineDispatcherを作り、CoroutinePluginのデフォルトのCoroutineDispatcherを差し替える

このTestRuleを使うと、Coroutineの非同期処理の完了までEspressoが待機するようになる。

Step3. UIテストコードの記述

TestRuleをテストクラスに適用する。以下、例。

@LargeTest
@RunWith(AndroidJUnit4::class)
class FooTests {
    @get:Rule
    val coroutineTestRule = CoroutineTestRule()
    ...
}

  1. Coroutineでもメインスレッドで実行する処理は、何も対処しなくてもEspressoが待機してくれるはず。 

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

【Androidアプリ開発】WebViewのJavaScriptからネイティブ�のメソッドを呼ぶ

ウェブページをネイティブで包んだアプリを作る際などに、Web側からネイティブアプリ側に情報を渡したいことがある。
普通にJavascriptInterfaceを使えばよいのだが、少しハマった部分があったのでメモしておく。

実装方法

基本的には公式の下記のページが参考になる。
https://developer.android.com/guide/webapps/webview?hl=ja#BindingJavaScript

  • 呼び出されるメソッドの定義
MainActivity.kt
/** Instantiate the interface and set the context  */
    class WebAppInterface(private val mContext: Context) {

        /** Show a toast from the web page  */
        @JavascriptInterface
        fun showToast(toast: String) {
            Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show()
        }
    }
  • WebViewにインターフェースを追加
MainActivity.kt
val webView: WebView = findViewById(R.id.webview)
    webView.addJavascriptInterface(WebAppInterface(this), "Android")
  • 呼び出すJS
test.html
<input type="button" value="Say hello" onClick="showAndroidToast('Hello Android!')" />

    <script type="text/javascript">
        function showAndroidToast(toast) {
            Android.showToast(toast);
        }
    </script>

ネイティブアプリ以外でも表示するサイトであれば、tryでくくってエラーが出ないようにしてあげた方がよさそう。

必要な記述

上記に従って実装してみたのだが動かない。
WebViewではデフォルトでJavaScriptが無効になっているようだ。
下記で有効にしてあげる必要があった。

MainActivity.kt
myWebView.getSettings().setJavaScriptEnabled(true)

おまけ

WebViewではストレージも無効になっていたので、必要な場合はオンにする。

MainActivity.kt
myWebView.getSettings().setDomStorageEnabled(true)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

おもむろにAndroidSDKのバージョン上げたらBLE通信できなくなったんだが

高校生の時に働いていたバイト先では昼だろうが夕方だろうがクリスマスだろうが関係なしに挨拶は「おはようございます」でした。

おはようございます!株式会社GxP 新卒から入社して7年目の向山です。
こちらはグロースエクスパートナーズ Advent Calendar 2020の24日目の記事になります。

例年であればクリスマスや忘年会など大人数でワイワイガヤガヤと飲み食いしたり遊んだりしていたかと思いますが、このご時世流石にそれは厳しいので家でのんびり静かなクリスマスイブをお過ごしでしょう。

多分、例年とギャップがありすぎてやることもなく時間が空いている結果こちらの記事に辿りついたかと思いますので、つい先日あった案件の苦労話でも聞いてください。

さっくりと背景

私の普段の業務は開発をベースにチームのスケジュール管理であったり、仕様の検討であったりと色んなことをやっています。
案件では「Javaを使ったWebアプリ」と「Xamarinを使ったAndroid/iOSのスマホアプリ」の開発を行っており、月に1回(主に月末)リリース作業を行っています。
スマホアプリではBluetooth Low Energy(以下「BLE」)を用いて機器との通信を行い、受信した値の履歴や変動を一覧やグラフで確認することが可能となっています。

そして今回の記事で主役となるのがこちらのAndroid版アプリとなります。

事件当日

11月を少し過ぎてしまい12月上旬、いつもより少し大きめの対応を済ませ、Android7~11とiOS12~14の実機によるリグレッションテストも完了。ここまで来てしまえばあとは各OSのストアへ公開するためにいつも通りアプリをアップロードしてリリースノート書いて申請が通るのを待つだけでした。

たまーに申請却下されるけど大した内容じゃないしへーきへーき

そう思っていた時期が私にもありました。

ストアにアプリを上げようとしただけなのに

先にiOS版アプリのアップロードを済ませ、リリースノートを書いて申請を出しました。問題ありません。
そのままAndroid版アプリもアップロードした時、画面には赤字で見慣れないメッセージが表示されました。

現在、お客様のアプリは API レベル 28 を対象にしています。セキュリティとパフォーマンスが最適化された最新の API を利用するには、API レベル 29 以上を対象にする必要があります。アプリの対象 API レベルを 29 以上に変更してください。

「はぇーマジか」これが私の第一声

どうやら2020/11/02以降、GooglePlayStoreへアプリをアップロードする際はtargetSdkVersionを29以上にしないとアップロードすらできないことになってしまったようです。なるほどなぁ。
ちなみに前回のリリースを行ったのが2020/10/29。ギリギリ影響を受けずにリリースできたようです。

赤字で表示されたTHE・エラーメッセージに私は一瞬戸惑っていましたが「単純にビルドで使ってるsdkのバージョン上げればええんやろ?リグレッションもっかいやんないといけんけどまぁしゃーない」と楽観的に考えながらポチポチとAndroidSDKのバージョンをAndroid9相当だった28からAndroid10相当の29へ変更して再ビルド。
特にエラーも発生しなかったのでリグレッションテストを始めるのでした。

が、しかし…

問題発生

AndroidSDKのバージョンを上げただけなのでiOS側には影響なしとして先述の通りAndroid7~11までの実機でリグレッションテストを進めていました。
Android7~9までのリグレッションテストが無事に完了し、Android10のリグレッションを開始。

するとなんということでしょう。
BLEを使った機器との通信が全くできません。ぴえん?

正確には機器と接続するために待受状態のBluetooth機器を画面の一覧を表示させているのですが、全くヒットしません。

一度ペアリングが完了した機器は端末のBluetooth設定にある接続済み機器の一覧から削除を行うことで再度接続が可能となるのですが、削除して接続を試みようとしても全くできません。ぴえんこえてぱおん??

私は何かを察したのかおもむろにAndroid11でも接続できるか試して見ました。
想定通りです。全く通信できません。本当にありがとうございました。

2020年9月時点でAndroid10のシェア率はおよそ3割のようです。1
Android9以前のバージョンでは問題なく動作するとは言ったものの、この状態でリリースするのは流石にクレームがくる…。

ここで私は素直に上長へ連絡してごめんなさいをしました。

ちなみにくっそどうでもいい話ですが、実はこの日の夜に同僚とご飯を食べに行く約束をしていたにも関わらず、今回の件と飲食店の時短営業も相まった結果行けなくなってしまったのでWパンチを食らい満身創痍でした。
問題が発生した翌日に有給取っていたのがせめてもの救い(リリースは救われてない)

原因の究明に向けて

弊アプリではBLE通信を行うためにPlugin.BLEを利用しています。
「動かなくなってしまったのであればライブラリを更新してしまえば良いのではないか」と思いアップデートを確認しました。
しかし、そこには昨年から更新が止まっているPlugin.BLE君の姿が…。
ライブラリを更新することによる問題の解消には繋がりませんでした。

次に確認したことは「Plugin.BLE以外のライブラリではどうなっているのか」でした。
BLE通信を行うためのライブラリは上記に挙げているPlugin.BLE以外にもSweetblueNordicなどが存在しています。
「Plugin.BLEには更新が無かったけど、他のライブラリでは今回発生しているAndroid10以降の端末でのBLE通信をどのように行っているのか」
「リリースノートや記事などに対応方針やヒントが書かれているのではないか」ということで調査することにしてみました。

すると、Sweetblueのドキュメント2にて以下の記載を見つけました。

In order to continue background scanning on Android 10, you need to add the new permission android.permission.ACCESS_BACKGROUND_LOCATION to your AndroidManifest.xml file.

AndroidManifest.xmlに位置情報の権限を追加すればよい、と。これで動くかもしれない!
私はウキウキでAndroidManifest.xmlを見に行き、権限を追加しようとしました。
しかし、そこには既に同じ権限が鎮座していたためそっと振り出しに戻ることにしました。

問題の解決

しばらく調査を続けているとStack Overflow3に以下の記載を見つけました。

if you target Android 10 then you need ACCESS_FINE_LOCATION to scan BLE. ACCESS_COARSE_LOCATION doesn't work anymore in Android 10.

BLEと通信するためには位置情報が必要な情報となっていますが、Android10では ACCESS_COARSE_LOCATION でなく ACCESS_FINE_LOCATION が必要との記載が。
まさか?お?これは、もしかして…いったーーーーーーーー!!!!

ということで、無事にAndroid10以降でも位置情報の権限を正しく設定することでBLE通信を行うことができるようになりました???

今まで弊アプリでAndroid10以降でもBLE通信ができていたのはAndroidSDKのバージョンが28だったためであり、Android9相当のアプリをAndroid10以降でも頑張って動かしていたということが分かりました。
そしてAndroidSDKのバージョンを29へ上げたことによってAndroid10以降の端末では権限周りが今までよりすこーし厳格化され、BLE通信を行うためには適切な権限を設定する必要があった、というお話でした。

一応技術っぽいことも書いておくと、以下のようにActivityCompatから権限情報を取得し、足りていなかったら利用者に許可していただくようにリクエストダイアログの表示を行っています。

// 位置情報に関する権限情報を取得
var isCoarseLocationGranted = ActivityCompat.CheckSelfPermission(Android.App.Application.Context, Manifest.Permission.AccessCoarseLocation) == (int)Permission.Granted;
var isFineLocationGranted = ActivityCompat.CheckSelfPermission(Android.App.Application.Context, Manifest.Permission.AccessFineLocation) == (int)Permission.Granted;

if (!isCoarseLocationGranted || !isFineLocationGranted)
{
    // 権限が不足している場合は画面に許可のリクエストダイアログを表示
    ...
}

ふりかえり

今回発生したAndroid版アプリをアップロードしてからエラーメッセージが表示され、AndroidSDKのバージョンを上げたらBLE通信ができなってしまった一連の流れですが、根本的な原因としては「GooglePlayで最低保証されているAndroidSDKバージョンの確認が遅れた」ということですね。
後から気付いた事ですが、AndroidDeveloperのガイドPlugin.BLEにもしっかりと ACCESS_FINE_LOCATION が必要となる記載がありました。
リリース当日までそのことを知らずに作業を進め、当日にあたふたしながら問題の解消となってしまったのは完全に日頃の情報収集不足が招いた事故なので、今後はSlackのチャンネルにGoogleやAppleなどの開発者向け情報を流すbotを設定してなるべく見逃しが無いようにしていきたいと思います。

さて、明日で25日間あったAdventCalendarも最終日ですね!
グロースエクスパートナーズ Advent Calendar 2020の最後は今月25日と26日に新宿中央公園で行われる「キャンドルナイト」についての記事を予定しておりますのでお楽しみに!

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

a

aa

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

LowMemoryKiller 〜AndroidのActivityが破棄される仕組み〜

この記事は、LIFULLその2 Advent Calendar 2020の23日目の記事です。:christmas_tree:

今回は、Androidの低レイヤーな話を取り上げてみようと思います。
具体的には、OOM Killerでプロセスがkillされるのを未然に防ぐLowMemoryKillerの仕組みについてです。

ネイティブアプリの開発は、よりメモリの事を意識した開発が必要だなと日々感じていたので、もっと低いレイヤーで何が行われているかをちゃんと理解したいと思ったのがきっかけで、勉強してきた内容になります。

読んでいただけたらこの辺の内容を理解できる内容になっていると思います。

  • LowMemoryKillerの仕組み
  • Activityが破棄される基準
  • onSaveInstanceState()Bundleに保存するデータは実際どこに保存されているのか

Androidエンジニアじゃなくても、Androidの世界を少し覗いた気になれるような記事になればいいなと思います。:eyes:

はじめに

Androidには、OOM Killerによってプロセスがkillされるのを未然に防ぐLowMemoryKillerという仕組みがあります。
AndroidでActivityが突如破棄されてしまうことがありますが、その仕組みに深く関係しているのがLowMemoryKillerです。

LowMemoryKillerの必要性

LowMemoryKillerの仕組みに入る前に、LowMemoryKillerがどんなときに必要になるか話をします。

まず前提知識になりますが、Androidアプリ開発でまず画面を用意するときは、Activityというコンポーネントを使用します。1つのActivityが1つの画面というイメージでここでは大丈夫です。(Activity上で複数の画面をFragmentで管理する設計もありますが、今回はFragmentは話に必要ないので省きます)
一般的に、1つのアプリには複数のActivtityが含まれ、1アプリは1プロセスで実行されます。

デフォルトでは、同じアプリのすべてのコンポーネントは同じプロセスで実行され、ほとんどのアプリでこの動作を変更する必要はありません。

アプリを閉じてもユーザーがアプリをkillしない限りバックグラウンドに残り続けます。Activityのインスタンスもプロセス内に残り続けます。

そのため、あるアプリがバックグラウンド時に他のアプリがメモリを要求した場合、アプリ側からみると以下のような段階を踏んで空きメモリを確保しようとします。

  1. メモリの開放
    • Activity#onLowMemory()Activity#onTrimMemory(int)が呼び出される
    • (Fragmentの破棄などのタイミングがここにあたる)
  2. Activityの破棄
    • Activity#onDestroy()が呼び出される
    • ただし、Activity#onSaveInstanceState(Bundle)が事前に呼び出され、必要な状態は保存される
  3. プロセスの終了
    • プロセスが終了される
    • この場合、実行中のActivityは終了するがActivity#onDestrory()は呼び出されない
    • アプリがActivity#onStop()で停止している状態であれば、onSaveInstanceState(Bundle)は呼び出された後となるため、Activityの状態復元は可能

このようにメモリが足りなくなると最終的にはアプリのプロセス終了に到ります。実際にプロセスの終了を行うカーネル側からみると、これがOOM Killerによってプロセスがkillされるタイミングですと、今ユーザーにとって重要なアプリでも問答無用でkillされることになってしまいます。

こんな事態にならないよう未然に防ぐのがLowMemoryKillerです。

OOM Killerよりもっと前の段階で、そのActivityが重要か重要じゃないかを判断して、重要度の低いActivityを持つプロセスからkillしていき、メモリを空けるように動きます。

  • OOM Killer
    • どのプロセスがkillされるかは運任せに近く、重要なプロセスでも問答無用でkillする
  • LowMemoryKiller
    • 重要度が低いActivityを持つプロセス(ex. バックグラウンドにいるActivity)から段階的にkillすることを試みる

このようにLowMemoryKillerが裏で不要なプロセスをよしなにkillしてくれるおかげで私達は、普段快適にAndroidのスマートフォンを利用できているわけです。

LowMemoryKillerの全体像

本題のLowMemoryKillerの仕組みについてです。

まずAndroidは、LinuxカーネルをベースとするOSであり、LowMemoryKillerは、Linuxカーネルに既にある機能をうまく活用して実現されている仕組みになります。ざっくり書くと、OOM Killerによってプロセスがkillされる前に重要度が低いActivityを持つプロセスをkillしてメモリを空ける仕組みです。

LowMemoryKillerの登場人物は主に3つです。

  • ActivityManagerService
    • 現在どのActivityが重要かを判断するActivityの管理人のような役割をする
    • ActivityStackというスタックでActivityを管理
    • Activityの重要度を示すスコアの更新依頼をカーネル側に行う
  • lmkdデーモン
    • oom_score_adj というスコアを更新する
  • lowmemorykillerドライバ
    • メモリの空きが少なくなってきたタイミングで表に出ていない重要度の低いアプリのプロセスのkillを試みる
    • 実際にここでプロセスをkillする仕組みには、Linuxのディスクキャッシュの開放を行うshrink_slab()を活用している

例としてA画面 → B画面へのActivity遷移が起きたときの全体像をざっくり図に表すと以下のようになります。
全体図.png

  1. まず、ActivityManagerServiceがActivity BをActivityStackに積む
  2. ActivityManagerServiceは、各Activityの重要度を示すスコアをカーネル側に伝達するため、スコアの更新依頼を行う
  3. lmkdデーモンがスコア(oom_score_adj)を更新する
  4. lowmemorykillerドライバは、端末のメモリに空きが少なくなってきたときに、lmkdで更新されるスコアを参照して、スコアの高いActivityを持つプロセスからkillしていき、メモリを解放することを試みる

※ただし、カーネル4.12の時点で、lowmemorykillerドライバはカーネルから削除されており、lmkdがメモリのモニタリングとプロセス強制終了タスクを実行するように変わっています。基本的に機能としてはlowmemorykillerドライバと同じ機能をサポートしていますが、Android10以降では、メモリプレッシャーの検出にカーネルプレッシャーストール情報(PSI)モニターを使用する、新しいlmkdモードがサポートされていたりします。詳しくは、ローメモリ キラー デーモン(Imkd)を参照ください。

Activityの重要度を示すスコア(oom_score_adj)

lmkdが更新するスコアに従って、メモリの空きが少なくなってきたときにActivityの破棄やプロセスのkillを段階的に行うので、このスコアをActivityが破棄される基準といえそうです。

このスコアは、oom_score_adjと呼ばれ、OOMKillerがプロセスをkillするときにも使用されます。

oom_score_adjは、プロセスとActivityのライフサイクルの状態によって決められるkillされる閾値(メモリ空き容量)を表します。

実際に自分の端末でこのスコアを確認してみました。
(闘値は固定ではなく端末の状態で上下します。)

$adb shell dumpsys activity oom

OOM levels:
    -900: SYSTEM_ADJ (   73,728K)
    -800: PERSISTENT_PROC_ADJ (   73,728K)
    -700: PERSISTENT_SERVICE_ADJ (   73,728K)
      0: FOREGROUND_APP_ADJ (   73,728K)
     100: VISIBLE_APP_ADJ (   92,160K)
     200: PERCEPTIBLE_APP_ADJ (  110,592K)
     250: PERCEPTIBLE_LOW_APP_ADJ (  129,024K)
     300: BACKUP_APP_ADJ (  221,184K)
     400: HEAVY_WEIGHT_APP_ADJ (  221,184K)
     500: SERVICE_ADJ (  221,184K)
     600: HOME_APP_ADJ (  221,184K)
     700: PREVIOUS_APP_ADJ (  221,184K)
     800: SERVICE_B_ADJ (  221,184K)
     900: CACHED_APP_MIN_ADJ (  221,184K)
     999: CACHED_APP_MAX_ADJ (  322,560K)
  • 先頭についている3桁の数字:システム内で生かすべき優先順位
  • [XXXX_XXX_ADJ]の語尾の()の中の数字:プロセスを終了する具体的なメモリの空き容量の闘値

システム内で生かすべき優先順位の数字がより小さいほどプロセスがkillされづらくなり、高いほどkillされやすくなります。システムや永続化サービスを除いた時、一番この中でkillされる可能性が低いのは、level:0のフォアグラウンドいるActivityということになります。

ちなみに、XXXX_XXX_ADJ の意味は、ProcessList.javaのソースコードを読めばわかります。

ActivityがフォアグラウンドにあればそのActivityのscoreはFOREGROUND_APP_ADJで0、1つ前に使っていたならPREVIOUS_APP_ADJで700、ホームのアプリのActivityならHOME_APP_ADJで600といった形で、Activityのライフサイクルの状態などによってスコアが変化します。
例えばActivityの画面遷移が起きた時のスコアの変遷は以下のようになります。

score.png

このようにActivityのライフサイクルが変わるたびに、ActivityManagerServiceが各Activityの重要度を判断して、スコアを更新するということです。実際にスコアを更新するのはlmkdです。

Androidを触っていると、”いつの間にかアプリがkillされている”ってことがありますが、内部的にはこのようにActivityのスコア更新を行って、メモリの空きが少なくなったときにはこのスコアの基準に従ってActivity破棄やプロセスのkillが行われているようです。

Bundleは実際どこに保存されているのか

アプリのプロセスがLowMemoryKillerによってkillされる話をここまでしてきましたが、Androidでは、破棄されたActivityを復元させることもできます。

LowMemoryKillerの必要性でも軽く触れましたが、その際は、onSaveInstanceState()でデータをBundleに保存しておき、Activityを復元するときには保存しておいたBundleのデータを取り出すことで、Activityが突然破棄された場合に対処します。これに関してはAndroidエンジニアなら常識になっていると思いますが、

このときBundleは実際どこに保存されているのでしょうか?:thinking:

その答えは、
SystemServerのプロセス上にあるActivityStackです。

先ほどのoom_score_adjの話を振り返ると、SystemServerのプロセスのoom_score_adjは-900になります。
アプリをバックグラウンドに移動すると、まず前回使っていたActivityとしてスコアは0から700になり、さらに時間が経つにつれてスコアが増えていくことを踏まえると、スコア-900のSystemServerのプロセスにデータを保存しておけばシステムから非常にkillされにくいということがいえます。

ただし、もちろんですがSystemServerのプロセスのメモリを使用するので、Bundleにあまり大きなデータを保存してはいけません。SystemServerのメモリが大きくなりすぎると、メモリを空けようとして、裏のActivityをkillする頻度が上がるため、タスクの切り替えがすごく遅くなったように感じたり、メモリ不足によるアプリの異常終了などが多くなってしまうようです。

またBundleにあまり大きなデータを保存しようとすると、TransactionTooLargeExceptionという例外も発生します。

The Binder transaction buffer has a limited fixed size, currently 1Mb, which is shared by all transactions in progress for the process.

Activity間の画面遷移を行う際に、Intentに載せられるExtraのペイロードの上限が1MBであることはこのようにドキュメントに書かれていますが、手元で何度かこのエラーに遭遇したときは実際は1MBいってなくても発生していました。

個人的な解釈としては、onSaveInstanceState()で保存するBundleにはidやBooleanなどだけで、大きなデータはViewModelか永続化領域に保存か、ネットワークから再取得するか、という使い方が適切なんだろうなと思います。
便利ですがBundleには、なんでもかんでもツッコまないようにしましょう。

おわりに

Linuxカーネルのより詳細な話も書こうとしたのですが、長くなりすぎるので本記事では省略しました。でも今回説明した仕組みをより詳細に知るためにはLinuxの知識も必要となります。気になる方はぜひ参考文献に載せている書籍などをご覧ください。:green_book:

普段何気なく書いてるコードもこうして裏では動いてくれているんだと思いをはせながら、来年は今年よりメモリに優しい開発をしていきたいなと思います。

メリークリスマス。よいお年を。:innocent:

参考文献

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

【Android】ワイアレスでアプリビルドする方法

はじめに

アプリをビルドする際にUSB-Cケーブルをつなげるのがめんどい人のため、
ワイアレスでビルドする方法を解説します

この記事を書いてる人

株式会社evoluの駆け出しAndroidエンジニア(3ヶ月目)

手順

  • adbコマンドを使えるようにする
  • 端末とホストPCの設定
  • ケーブルなしでも接続完了

adbコマンドを使えるようにする

terminalにて下記を実行

$ nano ~/.bash_profile

bash_profileにexportを追加する

export ANDROID_HOME=/Users/$USER/Library/Android/sdk
export PATH=${PATH}:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools

反映させる

$ source ~/.bash_profile

adbのバージョン確認ができたら完了になります

$ adb version

//Android Debug Bridge version 1.0.41

端末とホストPCの設定

  • pixcel 4aでの設定方法 設定 > システム > 開発者向けオプション > ワイアレスデバック > ペア設定コードによるデバイスのペア設定を選択

上記画像の場所まで辿りつけたら
- Wi-Fiペア設定コード
- IPアドレスとポート
の二つをメモします

次はこれらをホストPCに設定するためターミナルを使います

ターミナル

$ adb pair 192.xxx.x.xxx:xxxxxx

上記コマンド入れた後ペア設定コードを入れたら「Successfully paired to 〜」と表示されれば完了になります。

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

【Android】adbコマンドで、リモート接続する

Android端末にリモートで接続する方法です。
最初に有線で接続している必要があります。
至らない点があるかと思いますが、初投稿に付きご容赦願います。

前提条件

  • Android端末とPCが同じアクセスポイントに接続されている。
  • adbコマンドがインストールされている。
    • Android SDKのインストール
    • bash_profile(zprofile)の設定
  • 端末の「デベロッパーモード」が有効になっている。
  • 有線で接続されている。

adbコマンドでリモート接続する方法

まずは、端末をtcpモードに切り替えます(※ポート番号は自由に設定できる)
adb tcpip 5555
端末のIPアドレスを確認します
adb shell ip addr show wlan0
確認したIPアドレスに対して、接続します(※5555は省略可能)
adb connect xxx.xx.xx.xx:5555

最後に

AndroidStudioで確認すると、無線で実機デバッグすることができます。
また、机上から有線ケーブルを減らせるので、快適です。
注意点は、端末を再起動すると、IPアドレスが変わってしまうので、再度設定する必要があります。

今後、役に立てたら良いなと思う記事をドシドシ書いていきますので、よろしくお願いします33

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

【Android】WiFi経由でadb実機デバッグ

Android端末にリモートで接続する方法です。
最初に有線で接続している必要があります。
至らない点があるかと思いますが、初投稿に付きご容赦願います。

前提条件

  • Android端末とPCが同じアクセスポイントに接続されている。
  • adbコマンドがインストールされている。
    • Android SDKのインストール
    • bash_profile(zprofile)の設定
  • 端末の「デベロッパーモード」が有効になっている。
  • 有線で接続されている。

adbコマンドでリモート接続する方法

まずは、端末をtcpモードに切り替えます(※ポート番号は自由に設定できる)
adb tcpip 5555
端末のIPアドレスを確認します
adb shell ip addr show wlan0
確認したIPアドレスに対して、接続します(※5555は省略可能)
adb connect xxx.xx.xx.xx:5555

最後に

AndroidStudioで確認すると、無線で実機デバッグすることができます。
また、机上から有線ケーブルを減らせるので、快適です。
注意点は、端末を再起動すると、IPアドレスが変わってしまうので、再度設定する必要があります。

今後、役に立てたら良いなと思う記事をドシドシ書いていきますので、よろしくお願いします33

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

【Android】WiFi経由で実機デバッグするには

Android端末にWiFi経由で実機デバッグする方法です。
最初に有線で接続している必要があります。
至らない点があるかと思いますが、初投稿に付きご容赦願います。

前提条件

  • Android端末とPCが同じアクセスポイントに接続されている。
  • 有線で接続されている。

adbコマンドでリモート接続する方法

まずは、端末をtcpモードに切り替えます(※ポート番号は自由に設定できる)

adb tcpip 5555

端末のIPアドレスを確認します

adb shell ip addr show wlan0

確認したIPアドレスに対して、接続します(※5555は省略可能)

adb connect xxx.xx.xx.xx:5555

最後に

AndroidStudioで確認すると、無線で実機デバッグすることができます。
また、机上から有線ケーブルを減らせるので、快適です。
注意点は、端末を再起動すると、IPアドレスが変わってしまうので、再度設定する必要があります。

今後、役に立てたら良いなと思う記事をドシドシ書いていきますので、よろしくお願いします33

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

THETAプラグインでスリットスキャンを試す

この記事は RICOH THETA Advent Calendar 2020 の23日目の記事です。
アドベントカレンダーが終わっても、皆さんが作るTHETA関連(特にTHETAプラグイン)記事をまってます!
(過去の空箱を埋めていただくのも大歓迎です!)

はじめに

リコーの @KA-2 です。

弊社ではRICOH THETAという全周囲360度撮れるカメラを出しています。
RICOH THETA VRICOH THETA Z1は、OSにAndroidを採用しています。Androidアプリを作る感覚でTHETAをカスタマイズすることもでき、そのカスタマイズ機能を「プラグイン」と呼んでいます(詳細は本記事の末尾を参照)。

今回は、「こんな実験してみたよ」くらいの内容です。
動画はサマにならないけど、静止画としたらなんとか“それっぽく”なったかも。

一般カメラで「スリットスキャン」などと呼ばれる映像表現手法をTHETAでできるかな?と思い立って試してみた次第です。

スリットスキャンとは?

Webで検索すると、わかりやすそうな記事が沢山でてきます。
検索結果の上位にでてくるこちらなどは例が豊富でわかりやすいです。

一応、自分なりの説明もしておくと、「時間をずらした線で映像を作る」という手法だと解釈しています。「線」=スリット、「時間をずらす」=スキャンというこから、「スリットスキャン」と呼ばれているようです。

皆さんの身近なところで判りやすいものではコピー機などのスキャナーが、相当します。
「時間のズレ」が極小すぎてわからない例では、実はメカ式シャッター(フォーカルプレーンシャッター)という機構をもつカメラにおいて「シャッター速度がとても高速な時」も実はスリットを動かして映像ができあがっています。さらに、メカ式シャッターを持たないデジタルカメラ(特にスマートフォン)においてもこのような現象は起こっていまして、「ローリングシャッターゆがみ」などといわれていた現象を記憶している方もいらっしゃるかもしれません。電車の窓から動画を撮ると電柱が倒れて映ったり、動画の中を横切った車や電車がナナメに歪んで見える現象です。

このような歪みを積極的に起こしている撮影手法/映像表現手法です。

今回の実験では、スキャンする方向を上→下や下→上としましたが、
横方向にスキャンすると、歩行者が針のように映ったり、猫の胴体が無くなったり伸びたり、長い電車が縮んだり、スキャンをゆっくり行うとゴーストタウンエフェクト記事に近しい映像が撮れることもあります。

デジタルカメラがこんなにも進化する前には、フィルムカメラの光をコピー機用のラインスキャナーでスキャンして高解像度のデジタル映像を撮れるカメラを自作した方、なんていうのもお見掛けしたこともあります。

THETAでどうやったの

THETAプラグインでライブプリビューを扱いやすくするのプロジェクトをベースとして、プラグインが連続フレームをうけとると、「横1本だけ新しい映像として採用し、残りは捨てる」ということをひたすら繰り返しおこなっています。1コマ目だけは全部使いますけれども。

ですので、1画面の更新が遅くなります。画像の解像度が高いほど1コマ分の映像ができあがるのに時間を要します。

1024×512pixel 30fpsのライブプリビューですと映像はこんな感じ。1画面できあがるまでおよそ17秒かかるので、カメラの前で 9~10回転くらいすることも余裕です。

1024_18fps_横ライン上から下スキャンの場合.jpeg

この速度ですと、ドアの出入りを表現することがかなり難しく……
640×320 30fps でやっとこんな感じにできました。

640x320ならなんとか.jpeg

Web検索で出てくるミュージックビデオの例では、かなり高速なラインスキャンをしていることがわかります。

ソースコード

前述のとおり、こちらのプロジェクトのMainActivityの中のスレッド部分だけ手を加えています。
解像度やフレームレートを変更するには、WebUIのJavaScriptの固定値を変えたりもしますが、そこは以前の記事を調べてください。

ラインスキャンの映像を作っているスレッド部分だけ抜粋します。

MainActivity.java
    //==============================================================
    // OLED Thread
    //==============================================================
    public void drawOledThread() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                int outFps=0;
                long startTime = System.currentTimeMillis();
                Bitmap beforeBmp = null;

                int imgWidth=0;
                int imgHeight=0;
                int posWidth=0;
                int posHeight=0;
                Bitmap bmpSlitScan=null;

                while (mFinished == false) {

                    byte[] jpegFrame = latestLvFrame;
                    if ( jpegFrame != null ) {

                        //JPEG -> Bitmap
                        Bitmap bitmap = BitmapFactory.decodeByteArray(jpegFrame, 0, jpegFrame.length);

                        if ( bmpSlitScan != null ) {
                            //縦スキャン
                            Bitmap bmpSlit = Bitmap.createBitmap(bitmap, 0, posHeight, imgWidth, 1, null, true);
                            Canvas canvasSlitScan = new Canvas(bmpSlitScan);
                            Paint mPaint = new Paint();
                            canvasSlitScan.drawBitmap(bmpSlit, 0, posHeight, mPaint);

                        } else {
                            imgWidth=bitmap.getWidth();
                            imgHeight=bitmap.getHeight();
                            bmpSlitScan = Bitmap.createBitmap(imgWidth, imgHeight, Bitmap.Config.ARGB_8888);
                            Canvas canvasSlitScan = new Canvas(bmpSlitScan);
                            Paint mPaint = new Paint();
                            canvasSlitScan.drawBitmap(bitmap, 0, 0, mPaint);
                        }

                        /*
                        //縦スキャン位置更新 (上→下)
                        posHeight++;
                        if ( posHeight >= imgHeight ) {
                            posHeight=0;
                        }
                         */
                        //縦スキャン位置更新 (下→上)
                        posHeight--;
                        if ( posHeight < 0 ) {
                            posHeight = imgHeight-1;
                        }

                        //set result image
                        ByteArrayOutputStream baos = new ByteArrayOutputStream();
                        bmpSlitScan.compress(Bitmap.CompressFormat.JPEG, 100, baos);
                        latestFrame_Result = baos.toByteArray();

                        outFps++;
                    } else {
                        try {
                            Thread.sleep(33);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    long curTime = System.currentTimeMillis();
                    long diffTime = curTime - startTime;
                    if (diffTime >= 1000 ) {
                        Log.d(TAG, "[OLED]" + String.valueOf(outFps) + "[fps]" );
                        startTime = curTime;
                        outFps =0;
                    }

                }
            }
        }).start();
    }

jpegのフレームが受け取れるので、Android標準の画像処理メソッドを利用しやすくするためにbitmapに形式を変換したあと、Canvas や Paint をつかって1ライン更新し、WebUIに編集後映像を表示するために編集後の映像をjpegにしています。

以下のようにすると横スキャンです。

MainActivity.java
                            //横スキャン
                            Bitmap bmpSlit = Bitmap.createBitmap(bitmap, posWidth, 0, 1, imgHeight, null, true);
                            Canvas canvasSlitScan = new Canvas(bmpSlitScan);
                            Paint mPaint = new Paint();
                            canvasSlitScan.drawBitmap(bmpSlit, posWidth, 0, mPaint);

「上から下」「下から上」のスキャン方向切替は、posHeightの更新の仕方で切り替えています。
横方向スキャンをする場合には、posWidthをずらしていってください。

まとめ

ひとまずは、ライブプリビューで得られる映像の画質で時間をかければ、スリットスキャンの映像表現ができることが判りました。
それなりに使えるようにするには、解像度を落とさねばならず、、、
まだまだ卵の状態ですね、ストア公開はとうめん難しいなーという感触。
(過去映像をバッファしながらなめらかにする、そうするとNDKで実装して、と、かかなり工夫をせねば。。。)

あと、露出パラメータ(絞り値、シャッター速度、ISO感度)やホワイトバランスを固定しないとという注意点も。この実験ではオートなのでコマ間の差異が線でみえてしまってます。

こんなかんじで、いろいろな実験をくりかえしていきます。
みなさんも、多様なことを試してみてください!

RICOH THETAプラグインパートナープログラムについて

THETAプラグインをご存じない方はこちらをご覧ください。
パートナープログラムへの登録方法はこちらにもまとめてあります。
QiitaのRICOH THETAプラグイン開発者コミュニティ TOPページ「About」に便利な記事リンク集もあります。
興味を持たれた方はTwitterのフォローとTHETAプラグイン開発者コミュニティ(Slack)への参加もよろしくおねがいします。

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

Flutterアプリ開発高速化Tips

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

はじめに

こんにちは。NTTテクノクロスで、エバンジェリストとして活動している神原です。弊社では、高度専門人材向けのキャリアパスが準備されており、私はその中のエバンジェリスト領域でキャリアを積んでいます。

普段は主にモバイル/ウェアラブル/IoT関連の開発や技術支援、国内外の講演、各種執筆、ソフト道場研修講師技術ブログCSR活動(学生向けIT/キャリア教育)などを行なっています。プライベートでは、ランニングと英会話の楽しさに目覚め、日々取り組んでいます。

クロスプラットフォームの開発技術のトレンドの1つであるFlutterの開発テクニックをご紹介します。Flutterは登場してから一定の期間も経ち、商用事例も見かけるようになってきました。

今回は、Flutterを使ってアプリを開発するときに、 知っておくと開発高速化にきっとつながるTips をお伝えします。開発環境にも色々ありますが、今回は統合開発環境(IDE)の1つである Android Studio を対象とします。また、開発高速化と言っても、様々なアプローチがありますが、今回は以下の観点で紹介していきます。

  • Tips(1)ボイラープレートの排除
  • Tips(2)コーディング支援機能の活用
  • Tips(3)その他タスクの高速化

Tips(1)ボイラープレートの排除

Flutterでアプリを開発していると、こんなコードをよく見かけると思います。ちなみに、Stateful Widgetは現場では使わないよという別の議論はあろうかと思います。その通りですが、話がそれてしまうので、今回は触れないでおきます。

boilerplate1_before.png

boilerplate2_before.png

それぞれ、10行ほどのコードを書く必要があります。さらにアニメーションを実現しようと思ったら、内容によっては、Animation­Controllerのボイラープレート的な処理など、さらに長いコードを書くことになります。IDEが持つコード補完機能を使うとはしても、それなりに負担ではないでしょうか。このようなボイラープレートは、実は簡単に排除することができます。最初にご紹介するのは、標準で提供されているテンプレート機能です。

livetemplate_shortcut.gif

↑のような感じです。例えば、Stateless Widgetを作りたいときは、 stlessを入力後、Tabキー押下 だけです。同様に、Stateful Widgetを作りたいときは、 stfulを入力後、Tabキー押下です。では、これら呪文のような stlessやstful はどこで定義されているのでしょうか?Android Studioの設定画面を開くと、 Editor->Live Templates に、これらテンプレートを確認できるはずです(Android Studio向けの公式Flutterプラグインを導入している場合)。

boilerplate1_after.png

現時点で8個のテンプレートが提供されています。ただ、これでは物足りないと感じる方もいると思います:-P Flutter Snipets (開発者:George Herbertさん)というプラグインを導入することで、より多くのテンプレートを利用できます。ちなみにプラグインは、同設定画面のPluginsからプラグインの名前を検索することで、インストールできます。

boilerplate2_after.png

導入すると、このように、多くのテンプレートを利用できるようになります。

boilerplate3_after.png

Tips(2)コーディング支援機能の活用

Flutterでアプリ開発の醍醐味は何と言っても、 Hot Reload でしょう。コードを編集しては、リアルタイムに画面を確認してと、開発サイクルを高速に回していくことができます。コードをいじりながら、アプリの画面を少しずつ改造していくことも頻繁にあると思います。そんなときに便利なTipsを紹介します。例えば、このような改造を行うときには、 TextFieldウィジェット(テキストボックス) の周囲に Paddingウィジェット(パディング)を挿入することになります。

codingassist1.png

コードを自分で逐一入力していっても良いのですが、地味に面倒ではないでしょうか。そんなときは、エディタのコーディング支援機能を活用すると便利です。

code_assist.gif

起点とするTextFieldウィジェットにカーソルを当て、支援機能を呼び出すと、いくつかの候補がポップアップで表示されます。今回は、 Add Padding を選択すると追加できます。もし間違えてウィジェットを追加してしまった場合に、該当ウィジェットだけを削除する支援機能も便利です。ぜひ色々と試してみてください。

Tips(3)その他タスクの高速化

Flutterでアプリを作っていると、Dartのコーディング以外にも実施すべき作業があります。これらの高速化につながる便利プラグインを幾つかご紹介していきます。

a.パッケージの導入支援

開発中に、pub.dev(Flutter向けパッケージが公開されているサイト)を頻繁に使うと思います。このページ確認しつつ、 pubspec.yamlファイル(パッケージの導入時などに編集) を編集していくはずです。ただ、開発に慣れてくると、パッケージ名の名前は何となく覚えているということもあると思います。そんなときに便利なのが、Flutter Enhancement Suite (開発者:Marius Höflerさん)です。

例えば、blocライブラリを使いたいとしましょう。このプラグインを導入しておくと、

input_suite.gif

のように数文字入力だけで、pub.devを逐次検索して該当するパッケージをリストアップしてくれます。あとは、候補から選ぶだけです。よく使うパッケージは多少なり名前が頭に入っているはずなので、こちらを使うと効率化できるケースもあると思います。

b.パッケージのバージョンアップ支援

次に、パッケージのバージョンアップのときに役立つプラグインです。利用していたパッケージの開発が進み、知らないうちに新しいバージョンがリリースされていることもよくあると思います。そんなときに便利なのが、Flutter PubVersion Checker (開発者:Paulina Szklarskaさんです。例えば、shared_prefereceパッケージ(AndroidのSharedPreference機能のFlutter版)を使っていたとしましょう。このプラグインを導入しておくと、

ver_checker.gif

のように、 簡単に最新バージョンに更新 することができます。ただし、当然ながら、パッケージのバージョンアップに伴い、アプリの挙動が変わることもよくあります。そのため、動作確認は確実に行いましょう。

c.パッケージのバージョンアップ支援

最後に、画像を利用するアプリで役立つプラグインです。アプリ内で画像を利用するとき、その定義をpubspec.yamlファイルで行います。例えば、画像を追加するたびに、このファイルを編集するのは、あまりに大変です。

そんなときに便利なのが、flutter-img-sync (開発者:Lihahaさんです。プラグインを導入すると、Android Studioの[Tools]配下に、本ツール実行用のメニューが追加されます。そこで、 [Tools]→[FlutterImageSync] と選択することで、 assetsフォルダ配下にある画像のパスを認識し、pubspec.yamlファイルに反映 してくれます。

image_sync1.gif

また、うれしいのが、画像ファイルをDartのリソース管理クラス経由でアクセスできるようになります。 r.dart(画像ファイルへの参照に必要) をインポート後、

image_sync2.gif

このクラス経由でアクセスできるようになります。クラス経由でアクセスできるため、画像のファイル名を誤って記述するなどのミスを軽減できるはずです。

以上、3つの観点で、Flutterアプリを開発するときに知っておくと役立つかもしれないTipsをご紹介しました。実は他にも色々とあるのですが、長くなってきたので、また、別の機会にご紹介できればと思います。

おまけ

最後に余談として、Flutterと私のつながりについて、ご紹介したいと思います。
私は新しい技術が大好きなこともあり、Flutterについても世の中に登場した頃から触っています。コロナ禍に見舞われてしまった1年ではありましたが、社外やコミュニティの皆様とのご縁もあり、幾つかのセミナー/カンファレンスなどでオンライン登壇させていただきました。

こちらは、日本で開催された【Online】Flutter Meetup(主催:GDG Tokyo, Flutter Meetup Tokyo, WTM Tokyo)と、欧州・中東・アフリカで企画されたDroidcon EMEA 2020でお話しさせていただいたときの様子です。

presentation.png

オンラインゆえに世界中どこにいても参加できるメリットを強く感じました。海外カンファレンスは9回目の海外講演にして、初めてのオンライン登壇でした。聞いてくださっている方々の顔を直接見ることができない状況で進行していくため、いつもとは異なる感覚がありました。ただ、終わった後には、個別に話しかけていただいたり、メッセージいただいたりとフィードバックをいただけたのはよかったです。

オンラインカンファレンスの良さも多く感じた一方で、2021年は世界に平穏な日常が戻り、現地開催されるイベントも一部戻ってきて、開発者同士で直接お互いの顔を見ながら、語り合える世界も復活してほしいなとも祈っています(オンライン・現地とそれぞれ良いところありますよね)。

これからもFlutterに限らず、さまざまな分野の面白そうな技術に着目していこうと考えています。エバンジェリスト活動も広げていきたいと考えておりまして、何かありましたら、海外・国内限らず、お声がけいただけるとうれしいです!

おわりに

今回は「Flutterアプリ開発高速化Tips」について、ご紹介しました。 Advent Calendar 2020に最後までお付き合いいただき、ありがとうございました!

当社は様々な分野で、多くのエンジニアが活躍しています。興味を持ってくださった方は、NTTテクノクロス Advent Calendar 2020から、気になる記事を読んでいただけると幸いです。まだまだ大変な状況が続いている世の中ですが、皆様にとって良い年末年始となりますように!

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

strings.xmlを用いて、Androidアプリを多言語化してみよう!

はじめに

こんにちは、どぎーです。
皆さんはstrings.xmlを活用していますか?
今回は、strings.xmlを用いて簡単にアプリを多言語対応させるTipsを紹介します。

開発環境

  • AndroidStudio 4.0.1

手順

今回は、お手元のアプリを日本語と英語に対応させてゆきます。

日本語用のstrings.xmlを用意する。

まずはProjectツールウィンドウの表示を、AndroidビューからProjectビューに変更しましょう。
その後、resフォルダにvalues-jaフォルダを追加しましょう。
このフォルダで、日本語表記で使用するデータを管理します。
スクリーンショット 2020-12-23 0.41.13.png
スクリーンショット 2020-12-23 0.41.42.png

続いて、values-jaフォルダにstrings.xmlを追加しましょう。
スクリーンショット 2020-12-23 0.42.08.png
スクリーンショット 2020-12-23 0.42.38.png

strings.xmlが作成できたら、それをダブルクリックして編集できるようにしておきましょう。

ここでProjectツールウィンドウの表示をProjectビューからAndroidビューに変更してみましょう。
すると以下に示すように、各strings.xmlはstringsフォルダ内に表示されます。
先ほどvalues-jaフォルダに追加したstrings.xmlの右側には(ja)と表示されています。
スクリーンショット 2020-12-23 0.53.56.png

アプリで表示する文字列を各strings.xmlに記述する。

それでは早速、アプリで表示する文字列を用意してゆきましょう。
まずは英語の文字列を用意してゆきます。

res/values/strings.xml
<resources>
    <string name="app_name">My Application</string>
    <string name="text">Merry Christmas!</string>
</resources>

次に日本語の文字列を用意してゆきます。
このとき、nameの値は英語版のstrings.xmlと同じにしましょう。

res/values-ja/strings.xml
<resources>
    <string name="app_name">strings.xmlで多言語化</string>
    <string name="text">メリクリ三年柿八年</string>
</resources>

これにて文字列の準備は完了です。

記述した文字列を使用する。

今回は以下のように、TextViewのtextを@string/textに設定します。
スクリーンショット 2020-12-23 1.15.48.png
これにて文字列の設定は完了です。

アプリを起動して確認してみる。

最後に、アプリがちゃんと多言語対応しているか確認してゆきましょう。

まずは、AVD(エミュレーター)の言語を日本語に設定しましょう。
お使いのAVDのSettingsアプリを起動して、System > Languages & input > Languagesを開き、以下に示すように、日本語(日本)を一番上に設定します。
これでAVDが日本語表記になりました。
スクリーンショット 2020-12-23 1.24.01.png

それではアプリを起動してみましょう。
以下の画像のように日本語表記の文字列が表示されましたか?
スクリーンショット 2020-12-23 1.27.03.png

今度はEnglish(United States)を一番上に設定します。
これでAVDが英語表記となります。
スクリーンショット 2020-12-23 1.26.30.png

それではアプリを起動してみましょう。
以下の画像のように英語表記の文字列が表示されましたか?
スクリーンショット 2020-12-23 1.26.41.png
これでアプリの多言語化は完了です!
お疲れ様でした。

仕組み

Android OSには、使用したい言語で記述したstrings.xmlを所定のフォルダに入れることで、OSの言語設定に従って自動的に言語を切り替える仕組みが用意されています。
その所定のフォルダは、日本語ではvalues-jaとなっています。
また一般的には、values-言語コードです。
使用したい言語の言語コードを調べて、ぜひ他にも実装してみてください。

最後に一言、メリクリ三年柿八年。そして良いお年を!

参考文献

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

AndroidのUIテストで、非同期処理が完了するまで待機する(Rx編)

アプリのE2Eテストするとき、通信が完了するまで待つ必要がある。たとえば、E2Eテストにログイン操作があるとき。通信を待たないとログインの通信中にログイン後の画面を検証したり操作したりするので、当然テストは失敗する。

通信などの非同期処理をRxで実行しているとき、テストを待機させる方法を記述する。Androidアプリ、Espressoで使っているときの話。

実現手順

基本的な考え方

  • IdlingResourceを使う
  • プロダクトコード内のRxの非同期処理1つ1つにIdlingResourceを使った処理は仕込まない

RxJavaPlugins#setScheduleHandlerを使うと実現できる。Rxの全非同期処理の実行前後に、独自の処理を仕込めるので、それを利用する。

Step0. IdlingResourceの導入

大体Espresso入れるときに、セットで導入しているとは思うが、一応示しておく。

app/build.gradle
dependencies {
    // ここではプロダクトコードでIdlingResourceを使わないので、androidTestImplementation
    androidTestImplementation "androidx.test.espresso:espresso-idling-resource:3.3.0"
}

Step1. CountingIdlingResourceで実行中の非同期処理をカウントする

汎用性のある処理なのでJUnitのTestRuleとして実装する。

RxTestRule
class RxTestRule : TestRule {
    override fun apply(base: Statement?, description: Description?): Statement = object : Statement() {
        override fun evaluate() {
            val rxIdlingResource = CountingIdlingResource("RxJava2", true)

            RxJavaPlugins.setScheduleHandler { oldAction ->
                Runnable {
                    try {
                        rxIdlingResource.increment() // 非同期処理が開始前にカウントアップ
                        oldAction.run()
                    } finally {
                        rxIdlingResource.decrement() // 非同期処理が開始前にカウントダウン
                    }
                }
            }

            val registry = IdlingRegistry.getInstance()
            registry.register(rxIdlingResource) // EspressoにCountingIdlingResourceを監視させる

            try {
                base?.evaluate()
            } finally {
                registry.unregister(rxIdlingResource) // テスト終わったらCountingIdlingResourceの監視を解除する
            }
        }
    }

}

※evaluateの中で@Before, @Test, @Afterの処理が実行される。

Step2. Rxの非同期処理の実行数が1以上の間、EspressoのAPIが待機するようにする

先程作ったTestRuleをUIテストを実行するクラスに適用する。

@LargeTest
@RunWith(AndroidJUnit4::class)
class FooTests {
    @get:Rule
    val rxTestRule = RxTestRule()
    ...
}

これで少なくともRxの非同期処理の実行数が1以上の間は、sleepなしでEspressoのAPIが待機してくれるようになる。

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

Espressoを使ったUIテストで、Sleepなしで非同期処理が完了するまで待つ(Rx編)

アプリのE2Eテストするとき、通信が完了するまで待つ必要がある。たとえば、E2Eテストにログイン操作があるとき。通信を待たないとログインの通信中にログイン後の画面を検証したり操作したりするので、当然テストは失敗する。

通信などの非同期処理をRxで実行しているとき、テストを待機させる方法を記述する。Androidアプリ、Espressoで使っているときの話。

実現手順

基本的な考え方

  • IdlingResourceを使う
  • プロダクトコード内のRxの非同期処理1つ1つにIdlingResourceを使った処理は仕込まない

RxJavaPlugins#setScheduleHandlerを使うと実現できる。Rxの全非同期処理の実行前後に、独自の処理を仕込めるので、それを利用する。

Step0. IdlingResourceの導入

大体Espresso入れるときに、セットで導入しているとは思うが、一応示しておく。

app/build.gradle
dependencies {
    // ここではプロダクトコードでIdlingResourceを使わないので、androidTestImplementation
    androidTestImplementation "androidx.test.espresso:espresso-idling-resource:3.3.0"
}

Step1. CountingIdlingResourceで実行中の非同期処理をカウントする

汎用性のある処理なのでJUnitのTestRuleとして実装する。

RxTestRule
class RxTestRule : TestRule {
    override fun apply(base: Statement?, description: Description?): Statement = object : Statement() {
        override fun evaluate() {
            val rxIdlingResource = CountingIdlingResource("RxJava2", true)

            RxJavaPlugins.setScheduleHandler { oldAction ->
                Runnable {
                    try {
                        rxIdlingResource.increment() // 非同期処理が開始前にカウントアップ
                        oldAction.run()
                    } finally {
                        rxIdlingResource.decrement() // 非同期処理が開始前にカウントダウン
                    }
                }
            }

            val registry = IdlingRegistry.getInstance()
            registry.register(rxIdlingResource) // EspressoにCountingIdlingResourceを監視させる

            try {
                base?.evaluate()
            } finally {
                registry.unregister(rxIdlingResource) // テスト終わったらCountingIdlingResourceの監視を解除する
            }
        }
    }

}

※evaluateの中で@Before, @Test, @Afterの処理が実行される。

Step2. Rxの非同期処理の実行数が1以上の間、EspressoのAPIが待機するようにする

先程作ったTestRuleをUIテストを実行するクラスに適用する。

@LargeTest
@RunWith(AndroidJUnit4::class)
class FooTests {
    @get:Rule
    val rxTestRule = RxTestRule()
    ...
}

これで少なくともRxの非同期処理の実行数が1以上の間は、sleepなしでEspressoのAPIが待機してくれるようになる。

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