第4回コベチケの会

コベリンの最近の取り組みとして、業務などで役立ちそうな知見を共有する会を開催することになりました。 そのついでに発表に使ったアジェンダもそのまま公開してしまおうという豪快な企画です。

※ アジェンダをそのままコピペして公開したものなので若干見にくい箇所もあるかもしれませんが、ご了承ください。

MediaActionSoundとイヤホン出力 @takkumattsu

発生した問題

アナログイヤホンを挿している時にMediaActionSoundで音を鳴らすとイヤホンから鳴っている音が端末のスピーカーから鳴ってしまう

詳細

MediaActionSoundというOSのカメラ関連の音使う時に使うクラス。 自前でカメラアプリ的なのを作った時にシャッター音とかを鳴らすためのクラス。

別途MediaPlayerなどで音を再生している状態でMediaActionSoundで音を鳴らすと音の出力がイヤホン側から端末側に切り替わってしまう。

MediaActionSoundはシャッター音とかなのでマナーモードを貫通して音を鳴らしたりする挙動があると思うが、それに付随したバグな気がしている。

stackoverflowで検索しても全然情報はなかった

解決方法

MediaPlayerを使って自前で鳴らす camera_click.oggみたいな音声ファイルが/system/media/audio/ui/にあるのでそれを鳴らしている感じ。

public enum SystemSoundType {
    CameraClick("camera_click.ogg"),
    VideoRecord("VideoRecord.ogg"),
    VideoStop("VideoStop.ogg");
    final String fileName;
    SystemSoundType(String fileName){
        this.fileName = fileName;
    }
}
import android.content.Context;
import android.media.MediaPlayer;
import android.net.Uri;

import java.io.File;

public class SystemSoundPlayer {
    private MediaPlayer mAudioPlayer;

    public void play(Context context, SystemSoundType type) {
        try {
            String filePath = filePath(type.fileName);
            mAudioPlayer = MediaPlayer.create(context, Uri.parse(filePath));
            mAudioPlayer.setOnCompletionListener(mp -> {
                mAudioPlayer.reset();
                mAudioPlayer.release();
                mAudioPlayer = null;
            });
            if (mAudioPlayer != null) {
                mAudioPlayer.start();
            }
        } catch (Exception e) {
            if (mAudioPlayer != null) {
                mAudioPlayer.reset();
                mAudioPlayer.release();
                mAudioPlayer = null;
            }
        }

    }

    private String filePath(String fileName) throws Exception {
        // https://android.googlesource.com/platform/frameworks/base/+/master/media/java/android/media/MediaActionSound.java#51 より
        for (String path : new String[]{"/product/media/audio/ui/", "/system/media/audio/ui/",}) {
            File f = new File(path + fileName);
            if (f.exists()) {
                return path + fileName;
            }
        }
        throw new Exception(fileName + "のサウンド音が見つかりませんでした");
    }
}

ほとんど https://android.googlesource.com/platform/frameworks/base/+/master/media/java/android/media/MediaActionSound.java と同じ実装だけどね。 MediaActionSoundSoundPoolクラスを使って鳴らしているっぽい。

FLAG_AUDIBILITY_ENFORCEDが原因っぽいかな?

mSoundPool = new SoundPool.Builder()
                .setMaxStreams(NUM_MEDIA_SOUND_STREAMS)
                .setAudioAttributes(new AudioAttributes.Builder()
                    .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
                    .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED)
                    .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
                    .build())
                .build();

Androidで複雑なグリッドレイアウトを実現する方法3選 @numa08 id:numanuma08

グリッド上にレイアウトされた画像の上下に複雑なレイアウトがあり、さらに画面全体がスクロールする画面を実装する必要があったのでその実装方法としてどんなものがあるか考えた。 イメージ.jpeg (760.5 kB)

前提条件

  • グリッド部分に表示される画像は動的に決まるが、個数は20個未満と少ない
  • グリッド部分以外の要素も動的に決まるものがおおく、高さは要素の内容で変わってくる

今回はサンプルとして、ヘッダー部分に「吾輩は猫である」の冒頭部分のテキストを、画像として猫の画像を3列で20個表示するアプリを作ってみた。

flutterを使うバージョン

  • 簡単さ :◎
  • 導入しやすさ: △
    • 既存プロジェクトがflutterなら問題はない。

CustomScrollViewsliverリストに要素をSliverListSliverGridを使って並べていけば実現可能。CustomScrollViewはネストしたスクロール可能なViewをどんどん並べていくことで、flutterらしくレイアウトを宣言的に記述できる。

Scaffold(
      appBar: AppBar(),
      // スクロールする部分をCustomScrollViewで囲む
      body: CustomScrollView(
        // sliversに内部の要素を入れていく
        slivers: [
          // ヘッダー部分はSliverListを使う
          SliverList(
              delegate: SliverChildListDelegate([
            Text('''
// 省略          
''')
          ])),
          // グリッド部分はSliverGridを使う
          SliverGrid(
            delegate: SliverChildBuilderDelegate(
              (context, count) {
                return Image.network('https://placekitten.com/400/400');
              },
              childCount: 20,
            ),
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
              childAspectRatio: 1,
            ),
          )
        ],
      ),
    );

epoxyを使うバージョン

  • 簡単さ: ○
  • 導入しやすさ: △
    • 依存ライブラリの定義とアノテーションプロセッサの設定が必要
    • 独自のdsl的記法の習得も必要

airbnbのepoxyを使うと1つのRecyclerView内の要素をControllerを使って宣言的に書ける。Controllerのコードだけを見てみるとflutterのような手軽さがある。

fragmentのレイアウトファイル

EpoxyRecyclerViewを画面に表示する。LayoutManagerにGridLayoutManagerを指定し、spanCount=3として画像を3列で並べられるようにする。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".EpoxyFragment">

    <com.airbnb.epoxy.EpoxyRecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
        app:spanCount="3"
        tools:listitem="@layout/epoxy_view_holder_list_item_grid_image" />
</layout>

ヘッダーのレイアウトファイル

こちらも今回はTextViewを1つもつだけ。改行を含むテキストをリソースで定義するのがだるかったので、DataBindingでkotlinのコードから指定する。

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

    <data>

        <variable
            name="text"
            type="String" />
    </data>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@{text}"
        tools:text=" " />
</layout>

画像のレイアウトファイル

こちらもImageViewがあるだけ。BindingAdapterを使ってURLを指定するとGlideで画像を表示するがそのあたりは本筋じゃないので省略。

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

    <data>

        <variable
            name="imageUrl"
            type="String" />
    </data>

    <ImageView
        android:id="@+id/image"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:imgUrl="@{imageUrl}"
        tools:ignore="ContentDescription"
        tools:src="@mipmap/ic_launcher" />

</layout>

EpoxyController

TypedEpoxyControllerのサブクラスを作り、buildModels内でヘッダー部分と画像のグリッド要素を表示する。この部分だけに注目するとflutterのような宣言的な書き方ができている。

class EpoxyListController : TypedEpoxyController<List<String>>() {
    override fun buildModels(data: List<String>) {
        // ヘッダー部分に吾輩は猫であるの冒頭文を挿入する
        headerView {
            id("header")
            text(
                """
// 省略
    """.trimIndent()
            )
            // spanSizeOverride で3列分の幅を確保する
            spanSizeOverride { _, _, _ -> 3 }
        }

        data.forEachIndexed { idx, url ->
            // パラメータに渡されたURLリストを使って、画像の表示をする。
            listItemGridImage {
                id(idx)
                imageUrl(url)
            }
        }
    }
}

Fragment

onViewCreatedでコントローラーにデータをセットする。

class EpoxyFragment : Fragment(R.layout.fragment_epoxy) {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val controller = EpoxyListController()
        val binding = FragmentEpoxyBinding.bind(view)
        binding.apply {
            recycler.setController(controller)
        }
        controller.setData((1..20).map { "https://placekitten.com/400/400" })
    }
}

ConstraintLayoutとFlowを使う

  • 簡単さ: △
  • 導入しやすさ: ◎
    • 今どきConstraintLayoutを使ってないAndroidのプロジェクトなんてある・・・?(煽

Flowを使うと要素をグリッド状に並べられる。レイアウトで定義したConstraintLayoutとFlowにFragmentから動的に画像のViewを挿入して実現する。しかし、xmlで定義したレイアウトにコードから動的に要素を追加するのでわかりにくいっちゃわかりにくい。またRecyclerViewを使わないので、要素が増えてくると描画のパフォーマンス低下が懸念される。

Fragmentのレイアウト

ScrollViewの中にConstraintLayoutを配置し、Flowを配置する。Flowはflow_maxElementsWrap="3"low_wrapMode="aligned"を指定すると3列のグリッド表示を実現できる。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".FlowFragment">

    <data>

        <variable
            name="headerText"
            type="String" />
    </data>

    <ScrollView
        android:id="@+id/scroll"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/container"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <TextView
                android:id="@+id/header_title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@{headerText}"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <androidx.constraintlayout.helper.widget.Flow
                android:id="@+id/grid_flow"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:flow_maxElementsWrap="3"
                app:flow_wrapMode="aligned"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/header_title" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </ScrollView>
</layout>

Fragment

Flowを使うと要素のレイアウトはしてくれるが、サイズの決定はしないので事前に計算する必要がある。このあたりが専用のGridを使うケースと違うところ。要素に表示するViewを生成したらConstraintLayoutとFlow両方にaddViewしなければならない。

class FlowFragment : Fragment(R.layout.fragment_flow) {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val binding = FragmentFlowBinding.bind(view)
        binding.apply {
            headerText = """
 吾輩わがはいは猫である。名前はまだ無い。 //省略
            """.trimIndent()
            // 画像の横幅を計算する。今回は画面の幅の1/3
            val imageWidth =
                this@FlowFragment.requireActivity().windowManager.defaultDisplay.width / 3
            repeat(20) {
                // ImageViewを生成したら、ConstraintLayoutとFlowの両方にaddViewする
                val imageView = ImageView(requireContext()).apply {
                    id = View.generateViewId()
                    Glide.with(this)
                        .load("https://placekitten.com/400/400")
                        .override(imageWidth)
                        .into(this)
                }
                container.addView(
                    imageView
                )
                gridFlow.addView(imageView)
            }
        }
    }
}

Jetpack Component

  • 簡単さ: ◎
  • 導入しやすさ: △

Jetpack Composeを使えばkotlinのコードで宣言的にレイアウトの実装ができる。グリッドを実現するLazyVerticalGridもあるが複雑なレイアウトを実現するときはLazyColumnの方が良さそう。とは言え、2021/07/28時点でrc-02でAndroid StudioもCanary版が必要となるため稼働中のプロジェクトで使う場合は慎重になる。

Fragment

LazyColumnを使って最初にヘッダー部分のレイアウトを表示する。グリッドは表示する要素のリストをkotlinのwindowedを使ってカラムの数分のリストに分割してRowで描画する。余った部分は空のBoxを描画する。このとき、Modifier.weight(1f)を使うといい感じに横幅の計算が行われる。

@ExperimentalFoundationApi
@Composable
fun BodyLayout() {
    LazyColumn {
        item {
            Text(
                """
 吾輩わがはいは猫である。名前はまだ無い。 // 省略
        """.trimIndent(),
            )
        }
        items((1..20).map { R.drawable.place_kitten } // Coilで画像を表示しようとしたが、なぜかうまく行かなかったのでローカルファイルを読み込む
             // windowedを使って[[0, 1, 2], [3, 4, 5], [6, 7]]のようにカラムの数分のネストしたリストを作り出す
            .windowed(size = 3, step = 3, partialWindows = true)) { list ->
            // Rowで横に描画
            Row {
                repeat(3) { index ->
                    val imageId = list.getOrNull(index)
                    // 1行に3列分の要素がないときは、空のBoxを描画する
                    if (imageId != null) {
                        Image(
                            painter = painterResource(id = imageId),
                            contentDescription = null,
                            modifier = Modifier.weight(1f)
                        )
                    } else {
                        Box(modifier = Modifier.weight(1f))
                    }
                }
            }
        }
    }
}

class ComposeFragment : Fragment() {
    @ExperimentalFoundationApi
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                BodyLayout()
            }
        }
    }
}

所感

複雑なレイアウトの実装となると、flutterが一番楽そう。しかし既存プロジェクトがflutterでは無い場合つらみがあるので、そうなるとepoxyやJetpack Composeを使うのが良いようにも思える。その一方、epoxyの場合静的なレイアウト部分が多くなると恩恵もすくなくなるためConstraintLayoutでの実現も検討に上がる。Jetpack Composeは速いところ正式リリースしてほしい。

Typescript の真の const @mironal

const obj = {
    hoge: "aaaaa"
}
// エラーにならない (オノレ...)
obj.hoge = "bbbb"

辞書っぽい定数定義するときに困るね. 以下のような config を作っても定数にならないからミスりそう.

const APP_CONFIG = {
  key: "app key",
  secret: "app secret"
}

どうする?

as const する. const assertion と呼ぶ.

const obj = {
    hoge: "aaaaa"
} as const

// Error: Cannot assign to 'hoge' because it is a read-only property.(2540)
obj.hoge = "bbbb"

こうすると obj の型は以下のようになる

const obj: {
  readonly hoge: "aaaaa" 
}

以下ではない.

const obj: {
  readonly hoge: string
}
Object.freeze は?
const obj = Object.freeze({
    hoge: "aaaaa",
    aaa: {c:"ccc"}
})

// 代入できちゃうんだなコレが
obj.aaa.c = "bbbb"

// as const ならエラーになる
const obj = {
    hoge: "aaaaa",
    aaa: {c:"ccc"}
} as const

// Error: Cannot assign to 'c' because it is a read-only property.(2540)
obj.aaa.c = "bbbb"

// as const したほうはこういう型になる(手書きすると大変だね)
const obj: {
    readonly hoge: "aaaaa";
    readonly aaa: {
        readonly c: "ccc";
    };
}

実用的か知らないが、こういうふうに部分的に const にもできる

const VALUE = "CONSTANT VALUE" as const

const objIncludeConst = {
    val: VALUE,
    huga: "1234"
}

// Type '"aaaa"' is not assignable to type '"CONSTANT VALUE"'.(2322)
objIncludeConst.val = "aaaa"

// こっちは変更できる
objIncludeConst.huga = "ccc"

参考情報

税について @yanac

https://docs.google.com/presentation/d/1ROVoo7ansnhbzIwdk_zRm1bPBOKaUh-1xu_1xZ819Jc/edit?usp=sharing