ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

個人アプリをJetpackComposeでリプレースしたときの作業まとめ

こんにちは、tkyです。

個人で作成しているアプリをJetpackComposeで書き直したものを先日リリースしました。 どんなことを考えて実際に移行したのか記録として残しておこうと思います。

対戦ゲームの勝敗記録アプリ みんな使ってね!

play.google.com

■できること ・勝/負 の戦績を記録する ・勝敗にメモを残す ・戦績をゲームタイトルやイベント毎に記録する ・日、週、月ごとの対戦数と勝率をグラフで確認する ・過去にさかのぼって戦績を記録する ・記録した戦績の勝敗を編集する

画面構成

全部で4つの画面を持つ小規模なアプリです。

  • ゲーム一覧画面
  • 戦績記録画面
  • 分析画面(グラフのライブラリに依存するためリプレースはしませんでした)
  • 設定画面

戦績をグラフで表示する分析画面はグラフ表示ライブラリに大きく依存するためリプレースはしませんでした。

元のアプリの構成

Single Activityの4 Fragment 構成です。 NavigationとFragmentも密接に関わっているのでこのあたりはAndroid Frameworkに任せてしまって 各View(赤文字の部分)をjetpack compose化することに決めました。

いきなり全部やると破綻するし、末端の細かいパーツやUIから作ったほうが楽だなと思いました。

f:id:ticktakclock:20220217142347p:plain
jetpack compose化の画面構成

最初にどこから作る?

記録画面がメインとなる画面なのでそこから作り始めました。 一覧の画面からいきなり作っても良いですが、細かいパーツとか先に作っておいたほうがやりやすいかなという予測のもとメインの画面から着手しています。

ボタンから作りました

f:id:ticktakclock:20220217151146p:plain
ボタンを最初に作る

コードとしてもかんたんでCircleButtonという元のボタンを作って勝ったときのボタンと負けたときのボタンをそれぞれ作っています。

@Composable
fun ResultWinButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
    CircleButton(text = "Win", modifier.size(96.dp), onClick = onClick)
}

@Composable
fun ResultLoseButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
    CircleButton(text = "Lose", modifier.size(96.dp), onClick = onClick)
}

@Composable
private fun CircleButton(
    modifier: Modifier,
    text: String,
    onClick: () -> Unit,
) {
    Button(
        onClick = {
            onClick()
        },
        modifier = modifier,
        shape = CircleShape,
    ) {
        Text(text = text)
    }
}

次にカードを作りました

f:id:ticktakclock:20220217151235p:plain
次にカードを作ります

こちらについてはおおよそ以下の記事にまとめています。

Row/Column方式で作りました。

ticktakclock.hatenablog.com

ダイアログをどうするか問題

ダイアログについてはjetpack composeで書くこともできるのですが、今のままだと状態管理がちょっと手間だなというのと 既存のDialogFragmentそのまま流用できたので画面単位のjetpack compose化に集中しました。

ダークテーマについて

jetpack compose化する前から対応しています。 jetpack composeでもこのようなApplicationTheme上にUIを配置することでダークテーマに対応できます。

基本マテリアルデザインで提供されているデフォルトのカラーを使っています。

@Composable
fun ApplicationTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable() () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }

    MaterialTheme(
        colors = colors,
        typography = Typography,
        shapes = Shapes,
        content = content
    )
}

CardViewがelevationでSurfaceカラーが変わる?

ダークテーマ上の動作確認をしていてわかりましたがelevationの値によって自動で色が変わるみたいです。

上が8dp,下が16dpのElevationを設定しています。

f:id:ticktakclock:20220220233952p:plain
上が8dp,下が16dp

これはマテリアルデザインガイドラインのダークテーマ、Elevationの節で 高さによって白色透過色がオーバーレイされるという記述があります。

これを再現しているということになります。

material.io

デザイン上「カードの色はこれ」というように決まるかもしれませんが、マテリアルデザインのダークテーマ上だと「カードのelevation対して白色のオーバーレイが背景色に加算された色」

ということになるので齟齬が生まれやすいポイントかなと思いました。

背景に対して要素のelevationがどれだけあるかで背景色が決まることを知識として持っておきたいところですね。

style.xmlとJetpackComposeテーマの2つが共存しているということ

めちゃめちゃ混乱しました。 基本的にstyle.xmlの色を使ってくれますが、微妙に想定した色と違ったりして、どこの色を変更したら良いのかわからなくなってきたり。。。

途中でMaterial Degin Componentを使うようにスタイルの変更もしたため ダイアログのボタンのテキストカラーがColorAccent->ColorPrimaryに変更されていたりでボタンの色が見えなくなっていたり

動作確認しているとポロポロと色が見えない部分があって大変でした。

jetpack composeを利用するにあたって、色定義周りをしっかりしておいたほうが良いなと痛感しました・・・

- Theme.AppCompat.DayNight.NoActionBar
+ Theme.MaterialComponents.DayNight.NoActionBar
    <style name="ThemeOverlay.MyApp.MaterialCalendar" parent="ThemeOverlay.MaterialComponents.MaterialCalendar">
        <item name="buttonBarButtonStyle">@style/Widget.MyApp.Button.TextButton.Dialog
        </item>
        <item name="buttonBarNegativeButtonStyle">@style/Widget.MyApp.Button.TextButton.Dialog
        </item>
    </style>

    <style name="Widget.MyApp.Button.TextButton.Dialog" parent="Widget.MaterialComponents.Button.TextButton.Dialog">
        <item name="android:textColor">@color/colorAccent</item>
    </style>

ViewModelを変更することはなかった

ViewModelに関しては特に何も変更せずにfragment_gaems.xmlが GamesScreen()というコンポーザブル関数になるだけでほぼ完結しました。 しっかりとMVVMで作られているプレジェクトであれば結構すんなり置き換えできるかもしれないなと思いました。

Experimental APIは使わなかった

アプリ自体がリスト表示、カード、ボタンとかの標準的な使い方のみだった為だと思います。 プロダクトに導入するにあたってはリスト表示するような簡単な画面からやり始めるのが良いのかなと思います。

今後の展望

  • ダイアログ周りもjetpack compose化していきたい
  • Navigationはそのまま残すかも
  • jetpack compose関係ないけどデータ移行機能追加したい
  • jetpack compose関係ないけどworlのweb版作りたい

まとめ

  • 個人アプリをjetpack composeで書き直してリリースした
  • どこをjetpack composeにするか
    • navigationとFragmentはそのまま残して fragment_{screen_name}.xml を置き換えるようにした
  • 簡単なパーツから徐々に画面を構成していった
  • ViewModelはほとんど修正せずにxmlコードが激減した

  • みんな使ってみてね!

Discordのテキスト読み上げBOTを作る

こんにちは、tkyです。

普段自分が利用しているDiscordサーバー用にディスコードのテキスト読み上げBOTを作成したのでBOTを作ってみたいな、作るときにどういったところに気をつけて作ればよいのかと思っている人向けに一部共有します。

テキスト読み上げBOTとは?

Discordチャット欄の文字をボイスチャット上に読み上げてくれるDiscordのBOT機能を利用したアプリケーションです。

javascriptとdiscordjsで作成しています。

  • node v17.0.1
  • npm 8.1.0
  • discordjs 13.2.0 ★メインここの話をします★
  • GCP (Text To Speechに使います)
  • Heroku (サーバーとして利用します)

discord.js.org

あまりdiscordjs v13系で作っている人がいなくて結構苦労しました。

また、テキスト読み上げ(Text To Speech)にGCP、サーバーにHerokuを使っていますが、この辺の導入部分は割愛します。

GCPなくても一応読み上げは作れます(後述)

やること

BOTを作るところだけにフォーカスして話しますが、DiscordBOTの作成などもやる必要があります。

この辺は適宜ググっていただければと・・・

  • DiscordBOTを作成する
  • BOTを作る(コーディング)
  • GCPのTTSを有効にする(今回は使いません)
  • Herokuにアップロードする

npm install するものたち

npm install discord.js @discordjs/builders @discordjs/rest @discordjs/voice google-tts-api dotenv

discordjsとボイスチャット上でしゃべるのに必要なライブラリたちを入れます。

google-tts-api は本来は @google-cloud/text-to-speech 使ったほうが良いのですが、GCP登録とかが面倒でとりあえずBOTを動かしたい人向けに。

dotenvはdiscordの認証情報などをコードに書かないように環境変数から読み込めるようにするために入れています。

実装

解説用にコメント多めに入れておきます。 discordjs v13は過去バージョンv12に比べて使うクラスとかが微妙に違っていたりしたので苦労しました。。

// .envファイルに書かれている環境変数を適用します。
require('dotenv').config();
const { Client, Intents } = require('discord.js');
const { joinVoiceChannel, createAudioPlayer, StreamType,createAudioResource} = require('@discordjs/voice');
const googleTTS = require('google-tts-api'); // GCP使わない場合はこれを使います

// GCP使う場合はこの3つを入れます
const gcpTTS = require('@google-cloud/text-to-speech');
const fs = require('fs');
const util = require('util');

// Discordクライアントです
const client = new Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES] });

// クライアントのイベントハンドラです。
// ログイン処理後1度だけ呼ばれます。基本ログ出すだけです。
client.once('ready', () => {
    console.log(`Logged in as ${client.user.tag}!`);
});


// クライアントのイベントハンドラです。
// BOTが参加しているサーバーのチャット欄にメッセージが書き込まれたら呼ばれます。
client.on('messageCreate', async msg => {
    if (!msg.guild) return;
    try {
        // ここにメッセージに対する処理を書いていきます。
        // ここに書いても良いですが、長くなるので別関数に処理を流します。
        await messageCreateDelegate(msg);
    } catch(e) {
        // メッセージ処理に失敗した時用にtry-catchで括っておきます。
        msg.channel.send('エラーが発生してるよ。管理者に連絡してください。');
        console.log('messageCreateDelegate error :' + e);
    }
});

// ボイスチャットセッション保存用のMapです。
const subscriptions = new Map();
// 読み上げ対象のDicordチャンネル保存用のMapです。
const channels = new Map();


async function messageCreateDelegate(msg) {
    const {guildId, content, member, channel, channelId} = msg;
    let subscription = subscriptions.get(guildId);
    let thread = threads.get(guildId);
    // 次に詳細書きます。
}


// クライアントのイベントハンドラです。
// 接続エラーなどが発生したらログを出すだけです。
client.on('error', console.warn);

// BOTのログイン処理を行います。
// デフォルトで環境変数DISCORD_TOKENを使います。
client.login();

用語説明

  • Discordクライアント
    • Discordに接続するためのクライアントクラスです。
  • ギルド
    • Discordサーバーのことを指します。
    • ギルドIDはサーバーを固有に識別できるIDのことです。
  • チャンネル
    • Discordサーバー内のチャンネル(message/voice)のことを指します。
    • チャンネルIDはチャンネルを固有に識別できるIDのことです。
  • メンバー
    • Discordアカウントをメンバーと呼びます。
    • BOTもメンバーです。
  • ログイン
    • ログインするとBOTアカウントが🟢状態になります。

メッセージ処理の中身

おおよそこのあたりをやれば良さそうです。 結局は特定の文字列に対して何かするだけとなります。

特定のコマンドをトリガーにボイスチャットに参加する

不用意にコマンドに反応しないように $? などを接頭辞にするとよいかと思います

BOTのアプリ名は『ヨシ太郎』なので $yoshi としました。(コマンド指差し確認ヨシ!

async function messageCreateDelegate(msg) {
    const {guildId, content, member, channel, channelId} = msg;
    let subscription = subscriptions.get(guildId);
    let thread = threads.get(guildId);

    if (content.match(/^\$yoshi($| )/)) {
        const command = content.split(' ')[1] || 'join';
        // $yoshiは$yoshi joinと内部的に読み替えてコマンド処理します
        // 単純にそのほうがコマンドがなかった場合を考えなくてよいのとif-elseで全部書けたからです
        if (command === 'join') {
            if (!subscription){
                // yoshi-taroがボイスチャンネルに入っていなければ参加
                if (!member.voice.channelId) {
                    // メンバーがVCにいるかチェック
                    msg.channel.send('ボイチャに参加した状態でコマンド叩いてね😘');
                    return;
                }
                const connection = joinVoiceChannel({
                    selfMute: false,
                    channelId: member.voice.channelId,// メンバーが居るVCのチャンネル
                    guildId: guildId,
                    adapterCreator: msg.member.voice.guild.voiceAdapterCreator,
                });
                subscription = connection.subscribe(createAudioPlayer());
                connection.on('error', console.warn);
                subscriptions.set(guildId, subscription);
                channels.set(guildId, channelId);
                msg.channel.send('ワイはヨシ太郎や!"$yoshi help"で使い方表示するで!');
            }
            return;
        } else if (command === 'bye') {
            if (subscription) {
                subscription.connection.destroy();
                subscriptions.delete(guildId);
                channels.delete(guildId);
                msg.channel.send('お疲れさまでした👋');
            }
            return;
        }
        // コマンドを増やしたい場合は else ifでどんどん増やしていきます
    return;
    }
    // ・・・中略・・・
}

ポイントはコマンドを叩いた人がVCにいることをチェックすることと ボイスチャットセッションを保持しておくことです。

この時、ギルドID(サーバーを一意に特定できるID)に紐付けておくと複数のDiscordサーバーからの同時接続に対応できます。

subscription = connection.subscribe(createAudioPlayer());
connection.on('error', console.warn);
subscriptions.set(guildId, subscription);
channels.set(guildId, channelId);

特定のコマンドをトリガーにボイスチャットから退出する

上記に一緒に書きましたが $yoshi bye とコマンドを打つことで退出できるようにしました。

コマンドの作り方は自由です。ポイントは

ボイスチャットセッションを切って、サーバーのギルドIDに紐付いて保持していたセッション情報とチャンネル情報を消すことです。

subscription.connection.destroy();
subscriptions.delete(guildId);
channels.delete(guildId);

ボイスチャット参加中、特定のメッセージチャンネルの文字を読み上げる

何でもかんでも音声読み上げると困るので自分の発言と、コマンドを受け付けたチャンネル以外のメッセージでは読み上げないようにします

async function messageCreateDelegate(msg) {
    // ・・・中略・・・
    if (member.displayName === 'yoshi_taro') {
        // BOTのメッセージは無視する
        return;
    }
    if (channel != channelId) {
        // BOTが参加したチャンネル以外の発言は無視する
        return;
    }
    // ・・・中略・・・
}

絵文字も読まないようにしています。

上の方で後述しますと書いた部分で、 GCP使う場合も書きましたが、単純に試してみたいだけなら「GCP使わない場合はこっち」の方を使うと程よく楽にTTSが試せると思います。

GCPを使う場合は読み上げの性別や読み上げ速度などを設定できてより細かく調整できます。

async function messageCreateDelegate(msg) {
    // ・・・中略・・・
    const emoji = /[\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF]/ig;

    if (subscription) {
        let url = '';
        if (ttsService === 'gcp') {
            // GCP使う場合はこっち
            url = guildId + '.mp3';
            const request = {
                input: {text: content.replaceAll(emoji, '')},
            };
            // Performs the text-to-speech request
            const [response] = await gcpTTSClient.synthesizeSpeech(request);
            // 音声ファイルを書き出します。
            const writeFile = util.promisify(fs.writeFile);
            await writeFile(url, response.audioContent, 'binary');
        } else if (ttsService === 'free') {
            // GCP使わない場合はこっち
            // 音声ファイルのURLが取得できます。
            url = googleTTS.getAudioUrl(content..replaceAll(emoji, ''), {
                lang: 'ja',
                slow: false,
                host: 'https://translate.google.com',
            });
        } else {
            console.log('no tts service set.');
            return;
        }
        // ボイスチャットセッションの音声プレイヤーに音声ファイルのURLを指定して再生させます。
        const player = subscription.player;
        const resource = createAudioResource(url, {
            inputType: StreamType.Arbitrary,
        });
        player.play(resource);
    }
}

一応これで node index.js すればローカル上でBOT動作確認できるようになります。

※全部コードを載せたわけではないのでビルドエラーなどは適宜取り除いて上げる必要があるかもしれません

Herokuに環境変数を設定する

補足。.envをgit管理に上げるわけには行かないのでherokuの環境変数に - ディスコードBOTトークン - GCPのクレデンシャル(base64化したもの) を保存しています。

GCPのクレデンシャルはjson形式でファイルに置いておく必要があるため環境変数にはbase64化したものを設定して

.profile ファイルにjsonに書き出す処理を入れることでheroku環境にjsonを配置することができます

echo ${GOOGLE_CREDENTIALS} > /app/google-credentials.json

また、herokuはワーカー起動だと20日間しか動作できないため、クレジットカード登録することで24時間稼働できるようになります。

まとめ

  • すべてのコードは載せていないのであしからず
    • ただ主要な実装部分は全部乗っているはずです
  • 案外DiscordBOT作るの楽しかった
  • discordjsのバージョンが変わると実装も変わりそうで大変・・・

Row/ColumnとConstraintLayoutでどの程度レイアウトの作り方が違うのか

こんにちは、tkyです。

※Qiitaにも書いてありますが、こちらにも投稿しておきます。

jetpack composeでかんたんなレイアウトをそれぞれ - Row/Column/Box (※今回Boxは使っていません) - Constraint

で作成した場合、どのような違いが出るのか見てみました。

ConstraintLayoutのUIの作り方とかはこの記事では触れませんがコード見ていただいてもある程度理解できるかなと思います。

作ってみたのはこちら。 [search, share, home, star] というラベルが付いたアイコンを並べる

という部分をそれぞれRow/Columを使って実現したパターンとConstraintLayoutを使って実現したパターンとなります。

@Composable
fun RowColumnOrConstraintScreen() {
    Column(
        modifier = Modifier.padding(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text = "row,column composable")
        Card(modifier = Modifier.padding(16.dp)) {
            IconList(
                modifier = Modifier
                    .padding(8.dp)
                    .fillMaxWidth()
            )
        }
        Text(text = "constraint composable")
        Card(modifier = Modifier.padding(16.dp)) {
            IconListConstraint(
                modifier = Modifier
                    .padding(8.dp)
                    .fillMaxWidth()
            )
        }
    }
}

@Composable
@Preview
fun RowColumnOrConstraintScreenPreview() {
    RowColumnOrConstraintScreen()
}

こちらがプレビューの内容です

スクリーンショット 2021-10-22 0.58.30.png

Row/Column

以下を作ります。

  • 基本となるComposable関数
  • IconLabel
  • search, share, home, starそれぞれを表すComposable関数
  • IconLabelSearch, IconLabelShare, IconLabelHome, IconLabelStar
  • 一列に並べるComposable関数
  • IconList
@Composable
fun IconLabel(imageVector: ImageVector, label: String, modifier: Modifier) {
    Column(
        modifier = modifier.padding(vertical = 8.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Image(
            imageVector = imageVector,
            contentDescription = label,
            modifier = Modifier.padding(horizontal = 16.dp),
        )
        Text(text = label)
    }
}

@Composable
fun IconLabelSearch(modifier: Modifier = Modifier) {
    IconLabel(imageVector = Icons.Rounded.Search, label = "search", modifier = modifier)
}

@Composable
fun IconLabelShare(modifier: Modifier = Modifier) {
    IconLabel(imageVector = Icons.Rounded.Share, label = "share", modifier = modifier)
}

@Composable
fun IconLabelHome(modifier: Modifier = Modifier) {
    IconLabel(imageVector = Icons.Rounded.Home, label = "home", modifier = modifier)
}

@Composable
fun IconLabelStar(modifier: Modifier = Modifier) {
    IconLabel(imageVector = Icons.Rounded.Star, label = "star", modifier = modifier)
}

@Composable
fun IconList(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.SpaceEvenly,
    ) {
        IconLabelSearch()
        IconLabelShare()
        IconLabelHome()
        IconLabelStar()
    }
}

@Composable
@Preview
fun IconListPreview() {
    IconList(modifier = Modifier.fillMaxWidth())
}

こちらがプレビューの内容です。

スクリーンショット 2021-10-22 0.56.21.png

ConstraintLayout

Row/Columnと同様の構成で以下を作ります。

  • 基本となるComposable関数
  • IconLabelConstraint
  • search, share, home, starそれぞれを表すComposable関数
  • IconLabelSearchConstraint, IconLabelShareConstraint, IconLabelHomeConstraint, IconLabelStarConstraint
  • 一列に並べるComposable関数
  • IconListConstraint
@Composable
fun IconLabelConstraint(imageVector: ImageVector, label: String, modifier: Modifier = Modifier) {
    ConstraintLayout(
        modifier = modifier
    ) {
        val (rateVal) = createRefs()
        Image(imageVector = imageVector, contentDescription = label,
            modifier = Modifier.constrainAs(rateVal) {
                top.linkTo(parent.top, margin = 8.dp)
                start.linkTo(parent.start, margin = 16.dp)
                end.linkTo(parent.end, margin = 16.dp)
            })
        Text(text = label, modifier = Modifier.constrainAs(createRef()) {
            top.linkTo(rateVal.bottom)
            start.linkTo(rateVal.start)
            end.linkTo(rateVal.end)
            bottom.linkTo(parent.bottom, margin = 8.dp)
        })
    }
}

@Composable
fun IconLabelSearchConstraint(modifier: Modifier = Modifier) {
    IconLabelConstraint(imageVector = Icons.Rounded.Search, label = "search", modifier = modifier)
}

@Composable
fun IconLabelShareConstraint(modifier: Modifier = Modifier) {
    IconLabelConstraint(imageVector = Icons.Rounded.Share, label = "share", modifier = modifier)
}

@Composable
fun IconLabelHomeConstraint(modifier: Modifier = Modifier) {
    IconLabelConstraint(imageVector = Icons.Rounded.Home, label = "home", modifier = modifier)
}

@Composable
fun IconLabelStarConstraint(modifier: Modifier = Modifier) {
    IconLabelConstraint(imageVector = Icons.Rounded.Star, label = "star", modifier = modifier)
}

@Composable
fun IconListConstraint(modifier: Modifier = Modifier) {
    ConstraintLayout(
        modifier = modifier
    ) {
        val (search, share, home, star) = createRefs()
        IconLabelSearchConstraint(
            modifier = Modifier.constrainAs(search) {
                top.linkTo(parent.top)
                start.linkTo(parent.start)
                end.linkTo(share.start)
            })
        IconLabelShareConstraint(
            modifier = Modifier.constrainAs(share) {
                top.linkTo(parent.top)
                start.linkTo(search.end)
                end.linkTo(home.start)
            })
        IconLabelHomeConstraint(
            modifier = Modifier.constrainAs(home) {
                top.linkTo(parent.top)
                start.linkTo(share.end)
                end.linkTo(star.start)
            })
        IconLabelStarConstraint(
            modifier = Modifier.constrainAs(star) {
                top.linkTo(parent.top)
                start.linkTo(home.end)
                end.linkTo(parent.end)
            })
    }
}

@Composable
@Preview
fun IconListConstraintPreview() {
    IconListConstraint(modifier = Modifier.fillMaxWidth())
}

こちらがプレビューの内容です。 xmlのプレビューと同様に成約のマージンなどが可視化されているのが良いですね。

スクリーンショット 2021-10-22 1.02.04.png

どちらも同じレイアウトを実現できたが・・・

コードを見ていかがでしたでしょうか? このレベルのレイアウトならRow/Columnのほうがならべるだけで簡単で良さそうですね。

普段xmlでViewを作っているとついついViewを平たく保つためにConstraintLayoutを使ってしまいがちですが、 実際jetpack composeでConstraintLayoutを使ってみると結構な量で冗長なmodifierを書く必要があり(constrainAsの箇所のことです)、ちょっと読みづらいなと思いました。

Layout Inspectorでも見てみました

ConstraintLayoutとして木構造になっているので階層構造がこのレベルだとあまり深さの違いはわかりませんでした・・・

パフォーマンスについて

公式のドキュメントに言及があります。 ページをそのまま引用させていただきます。

https://developer.android.com/jetpack/compose/layouts/constraintlayout?hl=ja 注: View システムでは、大規模で複雑なレイアウトを作成する場合のおすすめの方法は ConstraintLayout でした。ネストされたビューよりもフラットなビュー階層のほうがパフォーマンスが優れていたためです。しかし、深いレイアウト階層を効率的に扱える Compose では、このような懸念はありません。 注: Compose の特定の UI に ConstraintLayout を使用するかどうかは、デベロッパーの好みによります。Android View システムでは、より高パフォーマンスのレイアウトを作成する方法として ConstraintLayout が使用されていましたが、この点は Compose では問題になりません。選択する必要がある場合は、ConstraintLayout がコンポーザブルの読みやすさと保守性に役立つかどうかを検討します。

ConstraintLayoutはマルチプラットフォームではない

ConstraintLayoutは現在マルチプラットフォーム化はされておらずAndroidで使えるライブラリとなっています。

Row/Column/Boxといった標準UIで構成できるようにしておくことで今後、 jetpack compose for Desktopやfor Webなどにも適用できるような基礎力が身につくのかなと思いました。

まとめ

実際ConstraintLayoutが煩雑になりやすそうなイメージを持ったので - 基本はRow/Column/BoxでUIを作っていく - 局所的に成約を設定したい場合にConstraintLayout使っても良さそう

くらいの認識になりました。

CompositionLocalProviderを使ったjetpack composeのバケツリレーの回避方法

こんにちは、tkyです。

※Qiitaにも書いてありますが、こちらにも投稿しておきます。

jetpack composeには下層のcomposeに依存を渡す方法があります。

  • KoinなどのDIコンテナを使う
  • CompositionLocalProviderを使う

今回はFragmentManagerを下層のcomposeに渡すサンプルを通して違いについて観察してみようと思います。

Koinを使ったパターン

こんな感じで下層のComposeにfragmentManagerをDIしてみます。

パット見てこのような印象を持ちます。 - Activityで何をDIしたいか一応わかる - MyScreenで必要な依存がわかる - ReactのContext APIと同じような感じ

val activityModule = { activity: AppCompatActivity ->
    module {
        // 本来はFragmentManagerを操作するInterfaceを別で定義したほうが依存が分けられるかと思います
        single { activity.supportFragmentManager }
    }
}

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        loadKoinModules(activityModule(this))
        setContent {
            JetpackcomposenavigationTheme {
                Surface(color = MaterialTheme.colors.background) {
                    MyApp()
                }
            }
        }
    }
}

@Composable
fun MyApp() {
    Scaffold(
        topBar = {
            TopAppBar(title = {
                Text("タイトル")
             })
        },
        content = {
            MyScreen()
        }
    )
}

@Composable
fun MyScreen(fragmentManager: FragmentManager = get()) {
    // ↑koinのget()メソッドでDIします↑
    AndroidView(factory = { context ->
        FrameLayout(context).apply {
            id = R.id.container
        }
    }, update = {
        val fragment = ComposeFragment.newInstance()
        val transaction = fragmentManager.beginTransaction()
        transaction.replace(it.id, fragment)
        transaction.commit()
    })
}

CompositionLocalProviderを使ったパターン

実はこれと同じことをやっています。

jetpack compose内でContextが欲しい時と、LifecycleOwnerが欲しい時ですね。

// Contextが欲しい時
val context = LocalContext.current
Toast.makeText(context, "hello jetpack compose", Toast.LENGTH_SHORT).show()

// LifecycleOwnerが欲しい時
val owner = LocalLifecycleOwner.current
owner.lifecycle.addObserver(viewModel)

この仕組みがCompositionLocalProviderです。

ポイントはこの部分です。CompositionLocalProviderのスコープ内で提供したインスタンスが利用できるようになります。

CompositionLocalProvider(
    LocalFragmentManager provides supportFragmentManager
) {
    MyApp()
}

コードをみてこのような印象を持ちます。 - Activityで何を提供したいのかわかる - MyAppはKoinパターンと違いはない - MyScreenの引数だけでは必要なものがわからない - FragmentManagerを使うスコープを限定できる

val LocalFragmentManager = staticCompositionLocalOf<FragmentManager> {
    noLocalProvidedFor("FragmentManager")
}

private fun noLocalProvidedFor(name: String): Nothing {
    error("CompositionLocal $name not present")
}

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        loadKoinModules(activityModule(this))
        setContent {
            JetpackcomposenavigationTheme {
                Surface(color = MaterialTheme.colors.background) {
                    CompositionLocalProvider(
                        LocalFragmentManager provides supportFragmentManager
                    ) {
                        MyApp()
                    }
                }
            }
        }
    }
}

@Composable
fun MyApp() {
    Scaffold(
        topBar = {
            TopAppBar(title = {
                Text("タイトル")
             })
        },
        content = {
            MyScreen()
        }
    )
}

@Composable
fun MyScreen() {
    val fragmentManager = LocalFragmentManager.current
    // Activityで提供したFragmentManagerをここで使う
    AndroidView(factory = { context ->
        FrameLayout(context).apply {
            id = R.id.container
        }
    }, update = {
        val fragment = ComposeFragment.newInstance()
        val transaction = fragmentManager.beginTransaction()
        transaction.replace(it.id, fragment)
        transaction.commit()
    })
}

使う分には大きな違いは感じませんが、 fun MyScreen() だけをみてfragmentManagerが必要だということが判別できず、関数の中を確認する必要がありますね。

とはいえDIコンテナを使わなくてもバケツリレーを回避できるという手段を持っておくだけでも実装の幅は広がりそうですね。

注意

CompositionLocalProviderは非常に便利に感じますがコードリーディングの観点から見ると いきなり知らない変数依存ができることにもなるので多用厳禁かなとは思いました。

基本的にはDIで解決できるしDIコンテナを利用したほうがより見やすくなると思います。

contextやlifecycleOwnerがCompositionLocalProviderで提供されているように フレームワークやライブラリなどシステム的にどうしても必要な場合でのみ利用を検討するのが良さそうです。

公式Doc

CompositionLocalに関する情報です。

https://developer.android.com/jetpack/compose/compositionlocal?hl=ja

jetpack composeでcompose上にFragmentをのせる

こんにちは、tkyです。

※Qiitaにも書いてありますが、こちらにも投稿しておきます。

jetpack composeでFragmentの上にjetpack composeを乗せるパターンはよく見ますが、逆はなかったので記事にしてみました。

Fragmentからjetpack composeを扱うこともできれば、jetpack composeからFragmentを扱うこともできるので便利ですね。

jetpack compose on fragment

よく見るFragmentでjetpack composeを扱う方法です。 ComposeViewというViewが提供されているのでこれをonCreateViewに返すようにします。

class ComposeFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                // この中でjetpack composeが扱えるようになります
                Text(text = "compose on fragment.")
            }
        }
    }
}

fragment on jetpack compose

AndroidViewというViewを扱うことができるComposableが提供されているのでこれを使います。

Fragmentに限った話ではなく、既存の資産を一時的に使いまわしたい時などに便利かもしれません。

部分

AndroidView(factory = { context ->
    FrameLayout(context).apply {
        id = R.id.container
    }
}, update = {
    val fragment = ComposeFragment.newInstance()
    val transaction = supportFragmentManager.beginTransaction()
    transaction.replace(it.id, fragment)
    transaction.commit()
})

全体

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackcomposenavigationTheme {
                Surface(color = MaterialTheme.colors.background) {
                    Scaffold(
                        topBar = {
                            TopAppBar(title = {
                                Text("タイトル")
                            })
                        },
                        content = {
                            AndroidView(factory = { context ->
                                FrameLayout(context).apply {
                                    id = R.id.container
                                }
                            }, update = {
                                val fragment = LegacyFragment.newInstance()
                                val transaction = supportFragmentManager.beginTransaction()
                                transaction.replace(it.id, fragment)
                                transaction.commit()
                            })
                        }
                    )
                }
            }
        }
    }
}

supportFragmentManagerをバケツリレーしなければならないのか?

上記サンプルコードはすべてActivityに書いていたのでComposeの中で supportFragmentManager にアクセスできました。

通常Activityの中に全て書き切ることはしないためまともにやろうとするとこうなります。 バケツリレーの嵐でこの先の地獄が見えますね。。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackcomposenavigationTheme {
                Surface(color = MaterialTheme.colors.background) {
                    MyApp(supportFragmentManager)
                }
            }
        }
    }
}

@Composable
fun MyApp(fragmentManager: FragmentManager) {
    Scaffold(
        topBar = {
            TopAppBar(title = {
                Text("タイトル")
             })
        },
        content = {
            MyScreen(fragmentManager)
        }
    )
}

@Composable
fun MyScreen(fragmentManager: FragmentManager) {
    AndroidView(factory = { context ->
        FrameLayout(context).apply {
            id = R.id.container
        }
    }, update = {
        val fragment = ComposeFragment.newInstance()
        val transaction = fragmentManager.beginTransaction()
        transaction.replace(it.id, fragment)
        transaction.commit()
    })
}

こういった場合はDIコンテナを利用することが理想系かなと思います。

Koinで解説しようと思います。 下記サンプルとしていきなりFragmentManagerをDIしていますが、実際は直接DIしないでFragmentを操作するインターフェースとしてDIしてあげるのが良さそうです。

val activityModule = { activity: AppCompatActivity ->
    module {
        // 本来はFragmentManagerを操作するInterfaceを別で定義したほうが依存が分けられるかと思います
        single { activity.supportFragmentManager }
    }
}

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        loadKoinModules(activityModule(this))
        setContent {
            JetpackcomposenavigationTheme {
                Surface(color = MaterialTheme.colors.background) {
                    MyApp()
                }
            }
        }
    }
}

@Composable
fun MyApp() {
    Scaffold(
        topBar = {
            TopAppBar(title = {
                Text("タイトル")
             })
        },
        content = {
            MyScreen()
        }
    )
}

@Composable
fun MyScreen(fragmentManager: FragmentManager = get()) {
    // ↑koinのget()メソッドでDIします↑
    AndroidView(factory = { context ->
        FrameLayout(context).apply {
            id = R.id.container
        }
    }, update = {
        val fragment = ComposeFragment.newInstance()
        val transaction = fragmentManager.beginTransaction()
        transaction.replace(it.id, fragment)
        transaction.commit()
    })
}

まとめ

  • composeの上にFragment(=AndroidのView)をのせることができた
  • 既存の資産を一時的に使いまわしたい時などに便利かもしれない
  • jetpack composeにはDIが不可欠

macの時刻がずれて治らない時にtimedプロセスkillしたら直った

こんにちは、tkyです。

伝えたいことはタイトルにすべて込めました!

なぜか突然Macの時刻がめちゃめちゃずれていて、試行錯誤の末直ったのでメモしておきます。

それはある日のこと

現在時刻2021年9月18日13時頃・・・

「あれ?なんで16時になってるの?てか8/31になってる!」

突然タイムリープしてしまったかと思いました。

f:id:ticktakclock:20210918132836p:plain
何故か時刻は8月31日16時36分・・・

再起動しても、シャットダウンしても直らない。どういうことなの・・・

時計の設定はサーバーと同期してある

何故かチェックをつけると8/31に戻されてしまって、正しい時刻が取得できない状態になってしまいました。

画像はすでに時刻が直っているものです。

f:id:ticktakclock:20210918133125p:plain
日付と時刻を自動的に設定

日本標準時を取得してみる

こちらのNTPサーバーから時刻を取得してみます

jjy.nict.go.jp

ticktakclockMacBook-Pro:~ ticktakclock$ date
2021831日 火曜日 164741秒 JST
ticktakclockMacBook-Pro:~ ticktakclock$ sudo sntp -S ntp.nict.jp
+1542602.443936 +/- 0.000000 ntp.nict.jp 133.243.238.243
// 自端末の時刻とどれだけ秒数乖離があるか返ってきます

めっちゃずれてる・・・w

timedプロセスをkillしたら直った

timedという時刻のプロセスをkillしたら直りました。というオチなのですが、なんでこうなってたのかはよくわかりませんでした・・・

ticktakclockMacBook-Pro:~ ticktakclock$ date
2021831日 火曜日 164741秒 JST
// めっちゃずれてる
ticktakclockMacBook-Pro:~ ticktakclock$ sudo sntp -S ntp.nict.jp
+1542602.443936 +/- 0.000000 ntp.nict.jp 133.243.238.243
// 自端末の時刻とどれだけ秒数乖離があるか返ってきます
ticktakclockMacBook-Pro:~ ticktakclock$ ps -ef |grep timed
  266   107     1   0  4:33PM ??         0:00.38 /usr/libexec/timed
  501  1170  1103   0  4:51PM ttys000    0:00.00 grep timed
ticktakclocknoMacBook-Pro:~ ticktakclock$ sudo kill 107
ticktakclocknoMacBook-Pro:~ ticktakclock$ sudo sntp -S ntp.nict.jp
+0.092306 +/- 0.000000 ntp.nict.jp 133.243.238.243
// 時刻差分が少なくなった!
ticktakclocknoMacBook-Pro:~ ticktakclock$ date
2021918日 土曜日 132307秒 JST
// 直った!

ちなみに参考はAppleのコミュニティの書き込みでした。

discussionsjapan.apple.com

普段Androidの記事などを書いていますが、 たまにはこういう記事も良いかも。

既存プロジェクトをAndroidStudio ArcticFoxでビルドできるようにするまで

こんにちは、tkyです。

現在携わっているAndroidプロジェクトの開発環境Android Studio4.1からArcticFoxに切り替えてビルドできるようにするまでをまとめてみました。

皆さんも同じようなところでハマるかもしれないのでこれを読んでおくと心構えができるかもしれません。

やることを列挙します。大きく5つくらいですが、AndroidStudioが出すエラーを1つずつ対応していけばなんとかなります。

  • ArcticFoxをインストールする
  • AGP(com.android.tools.build:gradle)を7系にする
  • JDK11にする
  • com.google.gms:google-serviceを最新(2021/8/21現在:4.3.10)にする
  • arch.lifecycle関連の依存があればandroidx.lifecycleに移行する

ArcticFoxをインストールする

当たり前ですが、新しい開発環境をインストールしましょう。

私は古いバージョンの4.x系も使用したいのもあり管理が楽な JetBrains ToolBoxを利用しています。

f:id:ticktakclock:20210821134630p:plain
Toolboxの管理例

developer.android.com

インストールしたらとりあえずビルドができないことを確認してみましょう!

AGPを7にする

今までは4系を使っていたはずですが、飛躍的に7系にアプデします。

理由は下記のリリースブログに記載の通りセマンティックバージョニングを採用したためですね。

  • 今まで -> AndroidStudioのバージョン番号に合わせていた
  • これから -> Gradle自体のバージョンに合わせる

developers-jp.googleblog.com

JDK11にする

AGP7にする要件にもなりますが、Javaのバージョンを上げる必要があります。

AndroidStudioのmenu>preferenceからGradleJDKのバージョンを11にダウンロード及び更新します。

f:id:ticktakclock:20210821135753p:plain
JDK11にする

GMSプラグインを最新にする

できる限り最新(2021/8/21現在:4.3.10)が望ましいですが、

『v4.3.9だとGradleのプラグインがうまく動作しない問題がある』

というところだけおさえておけば大丈夫だと思います。

ちなみにv4.3.9でもビルドできますが、起動時以下のエラーで即落ちることになります。

W/FirebaseApp: Default FirebaseApp failed to initialize because no default options were found. This usually means that com.google.gms:google-services was not applied to your gradle project.

androidx.lifecycleに移行する

これは人によって必要なことです。すでにサポート終了しているlifecycle-extensionsの依存がまだ残っていてビルドエラーとなって現れてきました。

以下のページを参考に自分が必要としているライブラリの依存を記述しましょう。

developer.android.com

まとめ

以上でおおよその方々がビルドできるようになったかと思います。 以降私のプロジェクトで個別で発生した問題を書いていきます。

余談ですが、この内容を自社のAndroidチームに共有したところ

「うちのプロジェクトではCIが通らなかったんですよね〜」

といった情報をもらえたり

「参考にしてうちのプロジェクトもArcticFoxになりました!」

のように参考にしてもらえたり良い影響が出ました。

みなさんもまずは身近な人に共有してみましょう!

その他ハマったこと、やったこと

'jcenter' is deprecated

まだJCenterの依存が残っていて消すと依存ライブラリでエラーが発生するので一旦このままにして、別途作業することにしました。

Firebase関連をBOM形式に記載し直した

ついでにBOMに記載し直しました。

こういう機会がないとなかなかbuild.gradleのメンテできないので良い機会でした。

firebase.google.com

protobuf-lite -> protobuf-javalifeに変更

API通信にprotoを使っています。

既存のprotobuf-liteはメンテされていない古いものだったらしく新しいprotobuf-javalifeに変更して対応しました。

その際に参考となったブログ記事がこちらです。助かりました、ありがとうございます。

h.s64.jp

Circle CIのGraldeオプションをちょっと変更

JDKのガベージコレクタの仕様が変わっているらしくSerialGCになっているようなので明示的にParallelGCに設定しました

-XX:+UseParallelGC

以上です。それでは楽しいAndroid Studioライフを👋