個人アプリをJetpackComposeでリプレースしたときの作業まとめ
こんにちは、tkyです。
個人で作成しているアプリをJetpackComposeで書き直したものを先日リリースしました。 どんなことを考えて実際に移行したのか記録として残しておこうと思います。
対戦ゲームの勝敗記録アプリ みんな使ってね!
■できること ・勝/負 の戦績を記録する ・勝敗にメモを残す ・戦績をゲームタイトルやイベント毎に記録する ・日、週、月ごとの対戦数と勝率をグラフで確認する ・過去にさかのぼって戦績を記録する ・記録した戦績の勝敗を編集する
画面構成
全部で4つの画面を持つ小規模なアプリです。
- ゲーム一覧画面
- 戦績記録画面
- 分析画面(グラフのライブラリに依存するためリプレースはしませんでした)
- 設定画面
戦績をグラフで表示する分析画面はグラフ表示ライブラリに大きく依存するためリプレースはしませんでした。
元のアプリの構成
Single Activityの4 Fragment 構成です。 NavigationとFragmentも密接に関わっているのでこのあたりはAndroid Frameworkに任せてしまって 各View(赤文字の部分)をjetpack compose化することに決めました。
いきなり全部やると破綻するし、末端の細かいパーツやUIから作ったほうが楽だなと思いました。
最初にどこから作る?
記録画面がメインとなる画面なのでそこから作り始めました。 一覧の画面からいきなり作っても良いですが、細かいパーツとか先に作っておいたほうがやりやすいかなという予測のもとメインの画面から着手しています。
ボタンから作りました
コードとしてもかんたんで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) } }
次にカードを作りました
こちらについてはおおよそ以下の記事にまとめています。
Row/Column方式で作りました。
ダイアログをどうするか問題
ダイアログについては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を設定しています。
これはマテリアルデザインガイドラインのダークテーマ、Elevationの節で 高さによって白色透過色がオーバーレイされるという記述があります。
これを再現しているということになります。
デザイン上「カードの色はこれ」というように決まるかもしれませんが、マテリアルデザインのダークテーマ上だと「カードの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版作りたい
まとめ
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 (サーバーとして利用します)
あまりdiscordjs v13系で作っている人がいなくて結構苦労しました。
また、テキスト読み上げ(Text To Speech)にGCP、サーバーにHerokuを使っていますが、この辺の導入部分は割愛します。
GCPなくても一応読み上げは作れます(後述)
やること
BOTを作るところだけにフォーカスして話しますが、DiscordBOTの作成などもやる必要があります。
この辺は適宜ググっていただければと・・・
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() }
こちらがプレビューの内容です
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()) }
こちらがプレビューの内容です。
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のプレビューと同様に成約のマージンなどが可視化されているのが良いですね。
どちらも同じレイアウトを実現できたが・・・
コードを見ていかがでしたでしょうか? このレベルのレイアウトなら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() }) }
まとめ
macの時刻がずれて治らない時にtimedプロセスkillしたら直った
こんにちは、tkyです。
伝えたいことはタイトルにすべて込めました!
なぜか突然Macの時刻がめちゃめちゃずれていて、試行錯誤の末直ったのでメモしておきます。
それはある日のこと
現在時刻2021年9月18日13時頃・・・
「あれ?なんで16時になってるの?てか8/31になってる!」
突然タイムリープしてしまったかと思いました。
再起動しても、シャットダウンしても直らない。どういうことなの・・・
時計の設定はサーバーと同期してある
何故かチェックをつけると8/31に戻されてしまって、正しい時刻が取得できない状態になってしまいました。
画像はすでに時刻が直っているものです。
日本標準時を取得してみる
こちらのNTPサーバーから時刻を取得してみます
ticktakclockMacBook-Pro:~ ticktakclock$ date 2021年 8月31日 火曜日 16時47分41秒 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 2021年 8月31日 火曜日 16時47分41秒 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 2021年 9月18日 土曜日 13時23分07秒 JST // 直った!
ちなみに参考はAppleのコミュニティの書き込みでした。
普段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を利用しています。
インストールしたらとりあえずビルドができないことを確認してみましょう!
AGPを7にする
今までは4系を使っていたはずですが、飛躍的に7系にアプデします。
理由は下記のリリースブログに記載の通りセマンティックバージョニングを採用したためですね。
- 今まで -> AndroidStudioのバージョン番号に合わせていた
- これから -> Gradle自体のバージョンに合わせる
JDK11にする
AGP7にする要件にもなりますが、Javaのバージョンを上げる必要があります。
AndroidStudioのmenu>preferenceからGradleJDKのバージョンを11にダウンロード及び更新します。
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の依存がまだ残っていてビルドエラーとなって現れてきました。
以下のページを参考に自分が必要としているライブラリの依存を記述しましょう。
まとめ
以上でおおよその方々がビルドできるようになったかと思います。 以降私のプロジェクトで個別で発生した問題を書いていきます。
余談ですが、この内容を自社のAndroidチームに共有したところ
「うちのプロジェクトではCIが通らなかったんですよね〜」
といった情報をもらえたり
「参考にしてうちのプロジェクトもArcticFoxになりました!」
のように参考にしてもらえたり良い影響が出ました。
みなさんもまずは身近な人に共有してみましょう!
その他ハマったこと、やったこと
'jcenter' is deprecated
まだJCenterの依存が残っていて消すと依存ライブラリでエラーが発生するので一旦このままにして、別途作業することにしました。
Firebase関連をBOM形式に記載し直した
ついでにBOMに記載し直しました。
こういう機会がないとなかなかbuild.gradleのメンテできないので良い機会でした。
protobuf-lite -> protobuf-javalifeに変更
API通信にprotoを使っています。
既存のprotobuf-liteはメンテされていない古いものだったらしく新しいprotobuf-javalifeに変更して対応しました。
その際に参考となったブログ記事がこちらです。助かりました、ありがとうございます。
Circle CIのGraldeオプションをちょっと変更
JDKのガベージコレクタの仕様が変わっているらしくSerialGCになっているようなので明示的にParallelGCに設定しました
-XX:+UseParallelGC
以上です。それでは楽しいAndroid Studioライフを👋