背景/概要
生成AIの活用がコード生成からドキュメンテーションに関する活用にも広がってきた。 一例としてCopilot knowledge basesがある。
Copilot以外にも似たサービスが増えているので、それらでも活用しやすいドキュメントを書くための要点を調査する。
なお、GitHubリポジトリと連携可能なサービスを調査対象にする。
生成AIの活用がコード生成からドキュメンテーションに関する活用にも広がってきた。 一例としてCopilot knowledge basesがある。
Copilot以外にも似たサービスが増えているので、それらでも活用しやすいドキュメントを書くための要点を調査する。
なお、GitHubリポジトリと連携可能なサービスを調査対象にする。
Androidのライブラリモジュールを作成するとconsumer-rules.pro
とproguard-rules.pro
も作成されますが、その役割を「難読化」という表面的なことしか知らずにいました。
このままではプロのAndroidエンジニアとは名乗れない気がしたので、ちゃんと調べようと思います。
本記事はその調査ログです。
「proguard」でググると、1件目には「アプリの圧縮、難読化、最適化」というAndroid Developersの記事が出てきました。
まずはそれを読み、難読化の概要を知りました。
難読化に関する要点
▼サンプルコード:リリースビルド時に難読化を有効にする
// build.gradle.kts android { buildTypes { getByName("release") { // リリースビルド時だけ、コードの圧縮、難読化、最適化を有効にする。 isMinifyEnabled = true // AGPによって実行されるリソースの圧縮を有効にする。 isShrinkResources = true // AGP同梱のデフォルトProGuardルールファイルを読み込む。 proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } ... }
R8の構成ファイルの章にはproguard-rules.pro
の説明がありました。
モジュール内のProGuardルールを記載するファイルなのは分かりましたが、保持するコードのカスタマイズの章で紹介された-keep
ルール以外の詳しい説明はなく、ProGuardマニュアルがリンクされていました。
consumer-rules.pro
もproguard-rules.pro
と同じくProGuardルールを記載するファイルだと思いますが、「アプリの圧縮、難読化、最適化」には記載がありませんでした。
改めて「consumer-rules.pro」でググると「Android ライブラリの作成」 がヒットしました。 そしてライブラリ モジュールの開発に関する考慮事項の章に詳細がありました。
要点
consumerProguardFiles
を利用することでAARファイルにProGuard構成ファイルを埋めるつまりは、AARを生成しないマルチモジュールビルドにライブラリモジュールのconsumer-rules.pro
は不要そうです。
▼サンプルコード:AARファイルにProGuard構成ファイルを埋め込む
// build.gradle.kts android { defaultConfig { consumerProguardFiles("consumer-rules.pro") } ... }
どちらもAGP同梱のデフォルトProGuardルールファイルです。
proguard-android-optimize.txtの文頭のコメントを見ると、「最適化したくない場合は、この設定ファイルの代わりにproguard-android.txtを使い」という記載があるので、最適化の有無が異なるようです。
R8の構成ファイルの章ではproguard-android-optimize.txt
の使用がおすすめされていますが、使用されているルールが悪いと指摘しているものもあるので、積極的には利用したくありません。(参考)
proguard-rules.pro
はモジュール内のProGuardルールを記載するファイル。consumerProguardFiles("consumer-rules.pro")
でAARファイルにProGuard構成ファイルを埋め込める。proguard-android-optimize.txt
はアプリの最適化ができるが、危険性もありそう。ブラー効果の流行
最近はアプリでブラー効果をよく見ます。
ブラー効果を使う理由は、次のようなものだと思います。
また注目の対象が画像の場合、それにブラーをかけたものを背景にするのをよく見ます。
アプリでブラーをかける懸念
一般的に画像処理は重いものです。
そのためブラー処理をアプリで実装すると以下ような懸念があります。
古い端末だとメモリ不足で処理ができないケースがあります。
サイズが大きい画像の処理ではそれだけメモリが必要になりますが、メモリ不足になるとアプリがクラッシュします。
またメモリ不足にならなくても、処理に時間がかかります。
それがメインスレッドで処理された場合はアプリが固まり、ユーザにストレスを与えてしまいます。
安全にブラーをかける方法を考えよう
Androidの端末はピンキリなので、上記の懸念に向き合う必要があります。
ただ大変なので、アプリでブラー処理をしないアプローチもあります。(後述)
でも安全なブラー処理ができるなら、UIが良くなり、アプリの競争力向上につながるはずです。
ライブラリ
BlurTransformation
を利用するModifier.blur()
(Android 12 以上)ライブラリ
BlurTransformation
を利用するJetpack Macrobenchmark
の FrameTimingMetric
を計測する。
テストコード: BlurBenchmark.kt
テスト環境
計測結果の見方
Pixel 7 の計測結果
Galaxy J5 の計測結果
全体的にAndroidViewが早いので、フルComposeでなければAndroidViewを選択するのが良さそうです。
Composeが良ければ、glide-transformations を使うのが、 Modifier.blur()
より良さそうです。
さらに選択肢を精査するためには、以下の観点で比較します。
メンテナンスのしやすさ
メンテナンスしづらくなる要素には以下のようなものがあります。
それぞれ比較してみたのが以下の表です。
ここであげたライブラリは全て非推奨のRenderScriptを利用しています。
そのため将来的にRenderScriptから移行が必要になった時に、ライブラリの更新を待つか、自前で実装したものに置き換える必要が出てきます。
パフォーマンス
非推奨のRenderScriptを利用しない方法は以下に絞られました。
Modifier.blur() はフレーム落ちが多いので、あまり使いたくありません。
AndroidViewの2つの方法は、どちらも同等のパフォーマンスです。
ちなみに公式ドキュメントには以下の記載がありました。
Android 12(API レベル 31)以降を対象としている場合は、Toolkit.blur() ではなく RenderEffect クラスを使用することをおすすめします。
https://developer.android.com/guide/topics/renderscript/migrate?hl=ja
ここまでの比較で、以下の方法が良いと思いました。
TODO
DataStoreはローカルのデータストレージのライブラリです。SharedPreference
の代替ソリューションとしても紹介されています。
DataStoreの特徴として、以下のようなものがあります。
Preferences DataStore
と Proto DataStore
の2種類がある。それぞれ詳しくみていきます。
DataStoreにはPreferences DataStore
とProto DataStore
の2種類があります。
特徴的な違いは「データ格納方法」と「タイプセーフかどうか」の2点です。
Preferences DataStore | Proto DataStore | |
---|---|---|
データ格納方法 | Key-Valueでデータ格納 | Protocol Buffersを利用して型付きオブジェクトを格納 |
タイプセーフかどうか | タイプセーフではない | タイプセーフである |
ユーザの年齢を記録しておくアプリを作るとします。 記録した年齢をDataStoreに読み書きする場合は、以下のような実装になります。
data class UserPreferences( val age: Int ) class UserPreferencesRepository( // ① DataStoreのインスタンス作成 private val dataStore: DataStore<Preferences> ) { private object PreferencesKeys { // ② キーの定義 val AGE: Preferences.Key<Int> = intPreferencesKey("age") } // ③ データの取得 val userPreferencesFlow: Flow<UserPreferences> = dataStore.data .map { preferences -> UserPreferences( age = preferences[PreferencesKeys.AGE] ?: -1 ) } // ④ データの更新 suspend fun updateAge(age: Int) { dataStore.edit { preferences -> preferences[PreferencesKeys.AGE] = age } } }
DataStore
自体はインタフェースです。
データ読み取る時の data
、書き込むときのupdateData()
だけが公開されています。
そして、ここでは大事なことが2点あります。
data
がFlow
になっていることupdateData()
がsuspend関数になっていることdata
がFlow
になっているので、利用側はこのFlowを購読することでPreferences
変更を購読することができます。
これによってPreferences
に記録したデータをSingle Source of Truthとして扱いやすくなります。
またupdateData()
がsuspend関数になっているということは、利用側はCoroutinesのスコープから利用する必要があります。
public interface DataStore<T> { public val data: Flow<T> public suspend fun updateData(transform: suspend (t: T) -> T): T }
DataStore.kt - Android Code Searchから抜粋
また、DataStore<T>
でジェネリクスを使っているのがわかります。
Preferences DataStore
を利用するはPreferences
を使います。
Preferences
自体は抽象クラスになっています。
ここで大事なことは以下の点です。
internal
になっていることpublic abstract class Preferences internal constructor() { public class Key<T> internal constructor(public val name: String) { ... } public class Pair<T> internal constructor(internal val key: Key<T>, internal val value: T) public abstract operator fun <T> contains(key: Key<T>): Boolean public abstract operator fun <T> get(key: Key<T>): T? public abstract fun asMap(): Map<Key<*>, Any> public fun toMutablePreferences(): MutablePreferences { return MutablePreferences(asMap().toMutableMap(), startFrozen = false) } public fun toPreferences(): Preferences { return MutablePreferences(asMap().toMutableMap(), startFrozen = true) } }
Preferences.kt - Android Code Searchから抜粋
そもそもPreferences
が抽象クラスというのもありますが、Preferences()
とやってインスタンスを作ることはできません。
ちなみにPreferences
を継承しているMutablePreferences
もコンストラクタがinternalになっています。
DataStore<Preferences>
のインスタンスを作るにはPreferenceDataStoreFactory#create
を利用します。
引数が4つあります。
corruptionHandler
→ ファイルを読み込んでシリアライズする時の例外をキャッチした時に実行される。この引数に渡すReplaceFileCorruptionHandler
では、その例外が起きた時に新しく書き込むためのデータを指定する。migrations
→ マイグレーションの指定。scope
→ IO処理と変換処理を行うコルーチンスコープ。デフォルト引数はCoroutineScope(Dispatchers.IO + SupervisorJob())
。produceFile
→ DataStoreが動作するためのファイルを返す関数。public object PreferenceDataStoreFactory { @JvmOverloads public fun create( corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null, migrations: List<DataMigration<Preferences>> = listOf(), scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), produceFile: () -> File ): DataStore<Preferences> { val delegate = DataStoreFactory.create( serializer = PreferencesSerializer, corruptionHandler = corruptionHandler, migrations = migrations, scope = scope ) { val file = produceFile() check(file.extension == PreferencesSerializer.fileExtension) { ... } file } return PreferenceDataStore(delegate) } }
create(...)
のブロック内では、引数をさらにDataStoreFactory.create(...)
に渡しているのがわかります。
PreferenceDataStoreFactory#create
の引数と比べるとserializer
が増えています。
serializer
→ DataStore<T>
で使われるT
型のSerializer。T
型はImmutableでなければならない。PreferencesSerializer
固定。create(...)
のブロック内ではSingleProcessDataStore
のインスタンスを作成しています。
public object DataStoreFactory { ... @JvmOverloads // Generate constructors for default params for java users. public fun <T> create( serializer: Serializer<T>, corruptionHandler: ReplaceFileCorruptionHandler<T>? = null, migrations: List<DataMigration<T>> = listOf(), scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), produceFile: () -> File ): DataStore<T> = SingleProcessDataStore( produceFile = produceFile, serializer = serializer, corruptionHandler = corruptionHandler ?: NoOpCorruptionHandler(), initTasksList = listOf(DataMigrationInitializer.getInitializer(migrations)), scope = scope ) }
DataStoreFactory.kt - Android Code Searchから抜粋
SingleProcessDataStore
はDataStore<T>
の実装クラスです。つまりはDataStoreを利用している時はこのクラスが実体になっています。
/** * Single process implementation of DataStore. This is NOT multi-process safe. */ internal class SingleProcessDataStore<T>( private val produceFile: () -> File, private val serializer: Serializer<T>, initTasksList: List<suspend (api: InitializerApi<T>) -> Unit> = emptyList(), private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler<T>(), private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) ) : DataStore<T> { ... }
SingleProcessDataStore.kt - Android Code Searchから抜粋
内部の処理については後述しますが、ここで重要なのは最初のコメント文に書かれています。
Single process implementation of DataStore. This is NOT multi-process safe.
ここでいう"process"は、Activity
やService
が動いているプロセスを指しています。
デフォルトでは同じアプリのActivity
などは同じプロセスで動きますが、独自のプロセスを設定することもできます。
つまりは、もし異なるプロセスからDataStoreを利用するのは安全ではないということが書かれています。
参考:プロセスとスレッドの概要 | Android Developers
ここまででDataStoreのインスタンスが作成されるまでの流れを追うことができました。
Preference DataStore
はKey-Valueでデータ格納するので、そのためのキーを定義する必要があります。
キーは型はPreferences.Key<T>
になります。(Preferences
の内部クラス)
ちなみにPreferences.Key<T>
のコンストラクタはinternal
になっています。
つまりは利用する側はPreferences.Key<String>(...)
のようにインスタンスを作ることができません。
そこで、キーの定義をするためにはintPreferencesKey(...)
というような関数が用意されています。
サンプルコードではval AGE: Preferences.Key<Int> = intPreferencesKey("age")
というコードでキーを定義しています。
これはInt
型の値を格納するためのキーを定義していますが、他の型も定義可能です。
Int
型 → intPreferencesKey
Double
型 → doublePreferencesKey
String
型 → stringPreferencesKey
Boolean
型 → booleanPreferencesKey
Float
型 → floatPreferencesKey
Long
型 → longPreferencesKey
Set<String>
型 → stringSetPreferencesKey
ちなみにSharedPreference
ではDouble
型を読み書きするインタフェースはありませんでした。
DataStore<T>#data
からデータを取得します。data
の戻り値はFlow<T>
になっていて、DataStore<Preferences>
を利用している場合はPreferences
が流れてきます。
そして流れてきたPreferences
からキーを使って値を取り出します。
ここでのポイントは以下です。
Preferences
が流れてくる。サンプルコードではdataStore.data.map { ... }
のブロック内でPreferencesKeys.AGE
を使って値を取り出していますが、他のキーの値が変わった場合は変更後のPreferences
が流れてくるので、PreferencesKeys.AGE
に対応する値に変更がなくてもそのブロック内の処理は動いています。
次に、DataStore<T>#data
にデータが放出されるまでの流れについてです。
前述の通り、DataStore<T>
の実体はSingleProcessDataStore<T>
です。
internal class SingleProcessDataStore<T>( ... ) : DataStore<T> { override val data: Flow<T> = flow { val currentDownStreamFlowState = downstreamFlow.value if (currentDownStreamFlowState !is Data) { // We need to send a read request because we don't have data yet. actor.offer(Message.Read(currentDownStreamFlowState)) } emitAll( downstreamFlow.dropWhile { if (currentDownStreamFlowState is Data<T> || currentDownStreamFlowState is Final<T> ) { // We don't need to drop any Data or Final values. false } else { // we need to drop the last seen state since it was either an exception or // wasn't yet initialized. Since we sent a message to actor, we *will* see a // new value. it === currentDownStreamFlowState } }.map { when (it) { is ReadException<T> -> throw it.readException is Final<T> -> throw it.finalException is Data<T> -> it.value is UnInitialized -> error( "This is a bug in DataStore. Please file a bug at: " + "https://issuetracker.google.com/issues/new?" + "component=907884&template=1466542" ) } } ) } ... @Suppress("UNCHECKED_CAST") private val downstreamFlow = MutableStateFlow(UnInitialized as State<T>) ... }
SingleProcessDataStore.kt - Android Code Searchから抜粋
まず、現在のdownStreamFlow
の状態がチェックされています。
downStreamFlow
はSingleProcessDataStore
のメンバになっていて、MutableStateFlow<State<T>>(...)
で初期化されています。
State<T>
はSingleProcessDataStore.kt
にファイルプライベートで定義されています。
状態は4つあります。
UnInitialized
→ 初期化前の状態Data<T>
→ ディスクから読み込みに成功した状態。現在のディスクの値を保持している。ReadException<T>
→ ディスクからの読み込みに失敗した状態。Final<T>
→ データの読み書きができなくなった状態。なお、downStreamFlow
の初期値はState.UnInitialized
になっています。
private sealed class State<T> private object UnInitialized : State<Any>() private class Data<T>(val value: T, val hashCode: Int) : State<T>() { fun checkHashCode() { check(value.hashCode() == hashCode) { "Data in DataStore was mutated but DataStore is only compatible with Immutable types." } } } private class ReadException<T>(val readException: Throwable) : State<T>() private class Final<T>(val finalException: Throwable) : State<T>()
SingleProcessDataStore.kt - Android Code Searchから抜粋
そしてdownStreamFlow
の状態がData<T>
ではない時は、actor.offer(Message.Read(currentDownStreamFlowState))
という呼出しをしています。
actor
もSingleProcessDataStore
のメンバになっていて、実体はSimpleActor
であることがわかります。
SimpleActor
の内部の役割は以下です。(メッセージのキューを管理しているだけなので、内部実装についての詳細は特に述べない)
scope
に与えたCoroutineScope
が持っているJobが完了したらonComplete
をコールバックする。onUndeliveredElement
でコールバックする。offer(...)
でメッセージをキューに追加する。consumeMessage
をコールバックする。そして、SingleProcessDataStore
でSimpleActor
のコールバック時に実行されている処理は以下の通りです。
onComplete
のコールバック時 → downstream
の状態をFinal<T>
にする。activeFiles
からfile
の絶対パスを削除する。onUndeliveredElement
のコールバック時 → Message.Update
型のメッセージが保持している遅延処理ack
を完了させる。consumeMessage
のコールバック時 → handleRead(...)
またはhandleUpdate(...)
を呼び出す。private val actor = SimpleActor<Message<T>>( scope = scope, onComplete = { it?.let { downstreamFlow.value = Final(it) } synchronized(activeFilesLock) { activeFiles.remove(file.absolutePath) } }, onUndeliveredElement = { msg, ex -> if (msg is Message.Update) { msg.ack.completeExceptionally( ex ?: CancellationException( "DataStore scope was cancelled before updateData could complete" ) ) } } ) { msg -> when (msg) { is Message.Read -> { handleRead(msg) } is Message.Update -> { handleUpdate(msg) } } }
つまりは、actor.offer(Message.Read(currentDownStreamFlowState))
を呼び出して、そのメッセージが処理されるとhandleRead(...)
が呼ばれることになります。
handleRead()
では改めてdownstream
の状態がチェックされています。そしてそれぞれの状態ごとに処理が分岐しています。
Data
→ 何もしないReadException
→ メッセージがキューに追加された時点から状態が変わっていなければreadAndInitOrPropagateFailure()
を呼び出す。UnInitialized
→ readAndInitOrPropagateFailure()
を呼び出すFinal
→ 例外を投げるprivate suspend fun handleRead(read: Message.Read<T>) { when (val currentState = downstreamFlow.value) { is Data -> { // We already have data so just return... } is ReadException -> { if (currentState === read.lastState) { readAndInitOrPropagateFailure() } // Someone else beat us but also failed. The collector has already // been signalled so we don't need to do anything. } UnInitialized -> { readAndInitOrPropagateFailure() } is Final -> error("Can't read in final state.") // won't happen } }
SingleProcessDataStore.kt - Android Code Searchから抜粋
readAndInitOrPropagateFailure()
ではさらにreadAndInit()
を呼び出しています。もしそこで例外が起きたら、キャッチしてdownstream
の状態をReadException
に更新しています。
private suspend fun readAndInitOrPropagateFailure() { try { readAndInit() } catch (throwable: Throwable) { downstreamFlow.value = ReadException(throwable) } }
readAndInit()
ではdownstreamFlow
の状態がUnInitialized
かReadException
の初期化ができていない状態かどうかチェックされています。
そのチェックが通ると、readDataOrHandleCorruption()
が呼ばれて初期データの読み込みがされます。
private suspend fun readAndInit() { // This should only be called if we don't already have cached data. check(downstreamFlow.value == UnInitialized || downstreamFlow.value is ReadException) val updateLock = Mutex() var initData = readDataOrHandleCorruption() var initializationComplete: Boolean = false // TODO(b/151635324): Consider using Context Element to throw an error on re-entrance. val api = object : InitializerApi<T> { override suspend fun updateData(transform: suspend (t: T) -> T): T { return updateLock.withLock() { if (initializationComplete) { throw IllegalStateException( "InitializerApi.updateData should not be " + "called after initialization is complete." ) } val newData = transform(initData) if (newData != initData) { writeData(newData) initData = newData } initData } } } initTasks?.forEach { it(api) } initTasks = null // Init tasks have run successfully, we don't need them anymore. updateLock.withLock { initializationComplete = true } downstreamFlow.value = Data(initData, initData.hashCode()) }
SingleProcessDataStore.kt - Android Code Searchから抜粋
readDataOrHandleCorruption()
ではreadData()
が呼ばれています。
private suspend fun readDataOrHandleCorruption(): T { try { return readData() } catch (ex: CorruptionException) { val newData: T = corruptionHandler.handleCorruption(ex) try { writeData(newData) } catch (writeEx: IOException) { // If we fail to write the handled data, add the new exception as a suppressed // exception. ex.addSuppressed(writeEx) throw ex } // If we reach this point, we've successfully replaced the data on disk with newData. return newData } }
SingleProcessDataStore.kt - Android Code Searchから抜粋
readData()
ではFileInputStream
が使われて、Preferences
のデータが書き込まれているファイルを読み込みしています。
serializer.readFrom()
が呼ばれていますが、Preferences DataStore
ではserializer
はPreferencesSerializer
になっています。
private suspend fun readData(): T { try { FileInputStream(file).use { stream -> return serializer.readFrom(stream) } } catch (ex: FileNotFoundException) { if (file.exists()) { throw ex } return serializer.defaultValue } }
serializer.readFrom()
の内部では、まずPreferencesMapCompat.readFrom(...)
が呼ばれています。
internal object PreferencesSerializer : Serializer<Preferences> { ... @Throws(IOException::class, CorruptionException::class) override suspend fun readFrom(input: InputStream): Preferences { val preferencesProto = PreferencesMapCompat.readFrom(input) val mutablePreferences = mutablePreferencesOf() preferencesProto.preferencesMap.forEach { (name, value) -> addProtoEntryToPreferences(name, value, mutablePreferences) } return mutablePreferences.toPreferences() } ... private fun addProtoEntryToPreferences( name: String, value: Value, mutablePreferences: MutablePreferences ) { return when (value.valueCase) { Value.ValueCase.BOOLEAN -> mutablePreferences[booleanPreferencesKey(name)] = value.boolean ... } } }
PreferencesSerializer.kt - Android Code Searchから抜粋
PreferencesMapCompat.readFrom(...)
の内部ではPreferencesProto.PreferenceMap.parseFrom(...)
が呼ばれています。
class PreferencesMapCompat { companion object { fun readFrom(input: InputStream): PreferencesProto.PreferenceMap { return try { PreferencesProto.PreferenceMap.parseFrom(input) } catch (ipbe: InvalidProtocolBufferException) { throw CorruptionException("Unable to parse preferences proto.", ipbe) } } }
PreferencesMapCompat.kt - Android Code Searchから抜粋
PreferencesProto.PreferenceMap.parseFrom(...)
の内部実装をさらに読み進めても、protobufのファイルをパースする処理が続いてあまり述べることがないのでここでは言及しませんが、ここで重要なのはPreferencesProto.PreferenceMap
のインスタンスを作っていることです。なお、フィールドにpreferences_
というMapも保持しています。
public static final class PreferenceMap extends GeneratedMessageLite<PreferencesProto.PreferenceMap, PreferencesProto.PreferenceMap.Builder> implements PreferencesProto.PreferenceMapOrBuilder { public static final int PREFERENCES_FIELD_NUMBER = 1; private MapFieldLite<String, PreferencesProto.Value> preferences_ = MapFieldLite.emptyMapField(); ... private PreferenceMap() { }
PreferencesSerializer#readFrom(...)
の処理に戻ると、preferencesProto.preferencesMap.forEach {...
の処理があります。
preferencesProto.preferencesMap
の内部処理を見ると、PreferencesProto.PreferenceMap
のフィールドで保持していたpreferences_
を返却しているのがわかります。
そして、値を取り出してaddProtoEntryToPreferences(...)
でmutablePreferences
にデータを追加していっています。
そうしてSingleProcessDataStore#readData
の処理にserializer.readFrom(...)
の戻り値が来たら、readDataOrHandleCorruption()
の呼び出し元のreadAndInit()
まで戻ることができます。
readAndInit()
内でinitData
が作られた後は、val api = object : InitializerApi<T> {
という処理があります。次の行ではinitTasks?.forEach { it(api) }
という処理もあり、initTasks
はマイグレーションの処理のリストなので、api
はマイグレーション時に生じる書き込み処理を実装しているのがわかります。
マイグレーションも完了したら、downstream
の状態をData
にしてreadAndInit()
の処理は終わっています。ここまで完了するとhandleRead()
の呼び出し元まで戻ることができます。
さて、SingleProcessDataStore#data
のFlowビルダーのブロックまで戻ってきました。
次にemitAll(downstreamFlow.dropWhile {...
の処理があります。
これはdownstream
の状態がData
またはFinal
になるまで待機して、今の状態から変わるたびにデータを放出します。なおPreferences
が放出されるのは状態がData
になった時だけです。
そしてPreferences
が放出されたら、ようやくDataStore<T>#data
の呼び出し元UserPreferencesRepository
にデータが流れてきます。
そうしたら② キーの定義
で定義したkeyを使ってPreferences
からデータを取り出しています。そしてサンプルコードではUserPreferences
というクラスでラップして扱いやすくしています。
DataStore<T>
のインタフェースにはupdateData()
というメソッドがありますが、Preferences
を扱う場合はDataStore<Preferences>#edit
という拡張関数が用意されています。
この拡張関数を利用しますが、内部的にはDataStore<T>#updateData()
を呼び出しています。
PreferenceDataStoreFactory
でDataStore
を生成しているので、実体はPreferenceDataStore
です。DataStore<T>.data
を呼び出した時はSingleProcessDataStore
に完全にdelegateしていましたが、updateData()
はオーバーライドしています。
ここでは引数のtransform
を使ってPreferences
の値を書き換えています。最終的にはdelegate#updateData
を呼び出しています。
internal class PreferenceDataStore(private val delegate: DataStore<Preferences>) : DataStore<Preferences> by delegate { override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { return delegate.updateData { val transformed = transform(it) ... (transformed as MutablePreferences).freeze() transformed } } }
PreferenceDataStoreFactory.kt - Android Code Searchから抜粋
delegate先のSingleProcessDataStore#updateData
を見ると、actor.offer(...)
を呼び出してメッセージのキューにMessage.Update
を追加しているのがわかります。
そしてactorで追加したMessage.Update
が処理されるとhandleUpdate(...)
が呼び出されます。
ここで気をつけたいのはack.await()
があることで、ここでMessage.Update
に渡しているack
(Deferred)が完了するまで待機しています。
internal class SingleProcessDataStore<T>( ... override suspend fun updateData(transform: suspend (t: T) -> T): T { val ack = CompletableDeferred<T>() val currentDownStreamFlowState = downstreamFlow.value val updateMsg = Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext) actor.offer(updateMsg) return ack.await() } ... }
handleUpdate()
の内部ではまず、downstream
の状態をチェックしています。
状態がData
の場合はtransformAndWrite(...)
が呼ばれています。
private suspend fun handleUpdate(update: Message.Update<T>) { ... update.ack.completeWith( runCatching { when (val currentState = downstreamFlow.value) { is Data -> { ... transformAndWrite(update.transform, update.callerContext) } is ReadException, is UnInitialized -> { if (currentState === update.lastState) { ... readAndInitOrPropagateAndThrowFailure() ... transformAndWrite(update.transform, update.callerContext) } else { ... throw (currentState as ReadException).readException } } is Final -> throw currentState.finalException // won't happen } } ) }
SingleProcessDataStore.kt - Android Code Searchから抜粋
transformAndWrite(...)
では現在のデータと新しいデータの比較がされています。同じデータだった場合は現在のデータcurData
を返しますが、異なるデータだった場合はwriteData(...)
が呼ばれています。
private suspend fun transformAndWrite( transform: suspend (t: T) -> T, callerContext: CoroutineContext ): T { // value is not null or an exception because we must have the value set by now so this cast // is safe. val curDataAndHash = downstreamFlow.value as Data<T> curDataAndHash.checkHashCode() val curData = curDataAndHash.value val newData = withContext(callerContext) { transform(curData) } // Check that curData has not changed... curDataAndHash.checkHashCode() return if (curData == newData) { curData } else { writeData(newData) downstreamFlow.value = Data(newData, newData.hashCode()) newData } }
SingleProcessDataStore.kt - Android Code Searchから抜粋
writeData(...)
では新しいデータをファイルに書き込んでいます。
まずfile.createParentDirectories()
が呼ばれて、ファイルの親ディレクトリが作られます。
その後はFileOutputStream
を使用してファイルの書き込みがされています。.use { ...
の中ではserializer.writeTo(...)
が呼ばれていて、serializer
はPreferencesSerializer
なのでそこでPreferences
用のファイルの書き込みがされています。またこのserializer.writeTo(...)
はsuspned関数になっていて、書き込み完了するまで中断しているのがわかります。
internal suspend fun writeData(newData: T) { file.createParentDirectories() val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX) try { FileOutputStream(scratchFile).use { stream -> serializer.writeTo(newData, UncloseableOutputStream(stream)) stream.fd.sync() // TODO(b/151635324): fsync the directory, otherwise a badly timed crash could // result in reverting to a previous state. } if (!scratchFile.renameTo(file)) { throw IOException( ... ) } } catch (ex: IOException) { if (scratchFile.exists()) { scratchFile.delete() // Swallow failure to delete } throw ex } }
SingleProcessDataStore.kt - Android Code Searchから抜粋
書き込みの処理が終わってtransformAndWrite()
に処理が戻るとdownstream
の状態を新しいデータで更新しています。
handleUpdate
の処理に戻って、downstream
の状態がReadException
とUnInitialized
の時の処理を見ると、まずreadAndInitOrPropagateAndThrowFailure()
が呼ばれています。
downstream
の状態がReadException
とUnInitialized
だとDataStore
の初期化ができていないので、readAndInitOrPropagateAndThrowFailure()
ではreadAndInit()
が呼ばれています。そこで初期が完了するとtransformAndWrite(...)
が呼ばれて新しいデータの書き込みがされます。
private suspend fun readAndInitOrPropagateAndThrowFailure() { try { readAndInit() } catch (throwable: Throwable) { downstreamFlow.value = ReadException(throwable) throw throwable } }
handleUpdate
の処理に戻って、downstream
の状態がFinal
の時の処理を見ると、例外が投げられています。
これで値を更新する一連の処理が完了しました。
導入自体はとっても簡単です。
gradleに依存関係を追加して、DataStore
のインスタンスを作って使い始めることができます。
dependencies {
implementation("androidx.datastore:datastore-preferences:1.0.0")
}
private val Context.dataStore by preferencesDataStore( name = USER_PREFERENCES_NAME, )
最近のアプリではアプリ アーキテクチャ ガイドを手本にしたアーキテクチャが多いと思います。またガイドの中でも述べられているようにDataStoreはアーキテクチャにおけるDataSource
の役割を果たします。
https://developer.android.com/topic/architecture/data-layer?hl=ja#architecture
したがって、DataStoreへのアクセスは、UIレイヤーから直接アクセスするのではなく、Repositoryを経由してアクセスするパターンが多くなると思います。
プロダクトのアーキテクチャに合わせてDataStore
のインスタンスを持ち方を考える必要があります。
サンプルコードではRepositoryのコンストラクタにDataStoreを渡していますが、注意するべき点があります。
xxx.preferences_pb
のようなデータを書き込む)に対して、DataStoreのインスタンスが1つだけになっていること(PreferenceDataStoreFactory#create
のコメントにもあり)これは1つのファイルを複数から操作するとデータの整合性が取れない場合があるからだと思われます。
class UserPreferencesRepository( private val dataStore: DataStore<Preferences> ) {
1つのファイルに対してDataStoreのインスタンスも1つにする方法としては、HiltでDataStore
のインスタンスをDIする方法が紹介されています。
https://medium.com/androiddevelopers/datastore-and-dependency-injection-ea32b95704e3
ただ、個人的にはRepositoryに直接DataStore
をInjectしない方が良いと考えています。理由は以下です。
DataStore
にテストデータをセットするのが手間になるRepository
のロジックをテストしたいだけなのに、都度DataStore
のセットアップが必要になるのは少々手間なので、RepositoryにはFakeのクラスを差し込めるようにした方が良いと思います。
そのため、DataStoreにアクセスするためのInterfaceを作り、そのinterfaceを実装しているクラスにDataStore
のインスタンスを持たせ、そのクラスをシングルトンで管理する方法にすると良いと思います。
DataStoreとSharedPreferenceのどちらにも言えることですが、keyの定義の重複を避ける必要があります。そのため、DataStoreの導入と併せてキーの重複をチェックできる仕組みを入れておくのが良いと思います。
例えば、sealedクラスでkeyを定義しておいて、重複がないかの単体テストを追加しておく方法があります。
class UserPreferencesDataStoreImpl @Inject constructor( ... ) : UserPreferencesDataStore { sealed class UserPreferencesKeys<T>( val preferencesKey: Preferences.Key<T> ) { object Age : UserPreferencesKeys<Int>(intPreferencesKey("age")) }
ここまでできればDataStore
を使い始める準備が出来ました。
この記事はRxJavaの基本的な5つの要素の学習ログです。
RxKotlinを用いたコードを載せています。
implementation("io.reactivex.rxjava3:rxkotlin:3.0.1")
RxJavaを学び始めると、まずReativeXの存在を知ります。
ReativeXについてReactiveX - Introでは以下のように説明されています。
ReactiveX is a library for composing asynchronous and event-based programs by using observable sequences.
ここで以下の2点がわかりました。
ちなみに「event-based programs」の部分を「event-driven programming(イベント駆動プログラミング)」と解釈しました。
イベント駆動プログラミングについて深掘りはしませんが、普段のアプリ開発でやっているsetOnClickListener
などがまさにイベント駆動プログラミングであるとわかりました。
また、リアクティブプログラミングについても触れられています。
これについても深掘りはしませんが、「データのストリームがあり、そこにデータが放出されたのを契機に処理したい内容を宣言的に記述するプログラミング」というように解釈しました。
ReactiveXは様々な言語で実装されていて、JavaScript、Swift、Scalaなどの言語で実装されています。
その中にJavaで実装されたものがあり、それがRxJavaと呼ばれています。
ちなみに、他の言語についてはReactiveX - Languagesで確認できます。
(言語が異なってもReactiveXの考え方は同じなので、別の言語で実装することになってもReactiveXの知識を活かすことができる)
Observableはデータをストリームに放出します。
簡単な例として、以下のコードで1~3の数字を放出するObservableを作っています。
いずれも放出する値は同じですが、Observableの作り方は何パターンかあります。
// Observable.just(...)を使う場合 val observable: Observable<Int> = Observable.just(1,2,3) // Observable.create(...)を使う場合 val observable: Observable<Int> = Observable.create { emitter -> emitter.onNext(1) emitter.onNext(2) emitter.onNext(3) emitter.onComplete() } // RxKotlinの拡張関数Iterable<T>.toObservable()を使う場合 val observable: Observable<Int> = listOf(1, 2, 3).toObservable()
Observable.create(...)を使う場合、emitter.onNext(...)
という呼び出しでデータを放出しています。
emitter
はEmitter
のインタフェースを実装していて、以下の3つのメソッドを持っています。
(正確にはObservableEmitter
を実装していて他のメソッドもありますが、基本的な呼び出しである3つのメソッドを挙げます。)
ちなみに、Observable.create { emitter ->
というラムダ式で書けるのはSAM変換によるものです。
以下のコードではデータが放出された時のコールバックだけ追加していますが、エラー時のコールバックやExceptionを指定するオーバーロードされたsubscribe
メソッドもあります。
observable.subscribe { value ->
println(value)
}
Operatorをコードで追加する場合は、Observableに続けて追記していきます。
以下のコードではfilter(...)
オペレータを使って、Observableに放出されたデータを偶数かどうかでフィルタしています。
val observable: Observable<Int> = listOf(1, 2, 3).toObservable() observable .filter { value -> value % 2 == 0 // 偶数のみ通す } .subscribe { value -> println(value) // 偶数のみ出力 }
Operatorは続けて追記することができます。
Operatorはfilter(...)
のいくつもあり初めから覚えようとするのは覚えるのは困難ですが、ReactiveX - Operatorsでどんなオペレータがあるのか見ることができます。
Observableと似たものにSingleがあります。
Observableは一連の値を放出しますが、Singleは常に1つだけ値を放出するかエラーを返すかします。
以下のコードでは1
を放出するSingleを作成しています。
val single: Single<Int> = Single.just(1) val single: Single<Int> = Single.create { emitter -> emitter.onSuccess(1) }
データを放出する場合はonSuccess
、エラーが起きた場合はonError
を呼び出します。
onError
が呼び出されると、subscribeも終了します。
以下のコードではデータが放出された時のコールバックを追加しています。
またmap(...)
オペレータを使っています。
single .map { value -> value * 10 // 値を10倍する } .subscribe { value -> println(value) }
SubjectはObservableとObserverを継承しています。
そのため以下のコードのように、Subjectをsubscribeすることもできるし、observerとして設定することもできます。
以下のコードを実行した場合、observable
が放出した1~3の値が、observable.subscribe(subject)
と書いたことによりsubject
に伝播して、subject.subscribe {...}
で出力されます。
val subject = PublishSubject.create<Int>() subject.subscribe { value -> println(value) } val observable: Observable<Int> = listOf(1, 2, 3).toObservable() observable .subscribe(subject)
出力結果
1 2 3
Subject自体はabstractクラスです。
そのためSubjectを継承したクラスを作り、具体的な処理を記述する必要があります。
RxJavaではいくつかのSubjectを継承したクラスがあり、ユースケースに合わせて使用することができます。
それらのSubjectの説明は以下の記事が参考になりました。
以下のコードはobserveOn(Schedulers.computation())
でオペレータの処理をするスレッドを切り替えています。
observeOn(...)
より下に記述したオペレータの処理をするスレッドを指定することになるので、filter(...)
とsubscribe(...)
のスレッドが切り替わっています。
以下のコードはsubscribeOn(Schedulers.io())
でObservableが動作するスレッドを指定しています。
以下のコードではスレッドをよく考えずに指定していますが、本来は用途を考えて適切なスレッドを選択する必要があります。
どのスレッドが適切なのかは以下の記事などを参考にしてください。
val observable: Observable<Int> = Observable.create { emitter -> println(Thread.currentThread().name) // RxCachedThreadScheduler - Schedulers.io()のスレッド emitter.onNext(1) emitter.onNext(2) emitter.onNext(3) emitter.onComplete() } observable .subscribeOn(Schedulers.io()) .observeOn(Schedulers.computation()) .filter { value -> println(Thread.currentThread().name) // RxComputationThreadPool - Schedulers.computation()のスレッド value % 2 == 0 } .subscribe { value -> println(Thread.currentThread().name) // RxComputationThreadPool - Schedulers.computation()のスレッド println(value) }
RxJavaの基本的な5つの要素を学習しましたが、実務で扱うには足りない知識がいくつもあります。
FlowableやCompletable、AndroidでRxJavaで扱うのに便利なRxAndroidなどたくさんありますが、引き続き勉強していこうと思います。
StyleはViewの外観を指定する属性(attribute)の集まりです。
そのため、Viewの属性の共通化したい時に使っていると思います。
ただ最近「レイアウトパラメータってStyleに書いていいのかなぁ」と疑問に思うことがありました。
※ レイアウトパラメータと言っているのは、ViewのXMLに記述する android:layout_...
から始まる属性のことです
<style name="Widget.AppTheme.Button.Green"> <item name="android:layout_width">wrap_content</item> // 書いていいの? <item name="android:layout_height">wrap_content</item> // 書いていいの? <item name="backgroundTint">@color/background_tint_green</item> <item name="android:textColor">@android:color/white</item> </style>
もちろん書くのは問題ないし、同じレイアウトパラメータを書く手間も減ります。
でもこの記事を読んでから疑問に思うようになりました。
A style is a collection of view attribute values. https://medium.com/androiddevelopers/android-styling-themes-vs-styles-ebe05f917578
日本語訳すると「StyleはViewの属性の集まり」なので、これまでのStyleの説明と何ら変わらない気がします。
しかし view attribute values
は ViewのXML attributesに定義されているものを指しているのかなと思いました。
そのように考えるとレイアウトパラメータの android:layout_width
や android:layout_height
はViewのXML attributesには定義されていないので、Styleに書く対象ではないと考えることもできます。
ちなみに android:layout_width
と android:layout_height
はViewGroup.LayoutParamsのXML attributesに定義されています。
そのため android:layout_width
と android:layout_height
はViewの属性というより、ViewGroup.LayoutParamsの属性といった方がより正確かもしれません。
StyleにLayoutParamsを書くことは問題ありません。
ただ意識すべきことが2点あると思います。
android:layout_...
の値をチェックしてLayoutParamsを決定するのは親のレイアウトであること(参考:レイアウト | Android Developers)ViewはLayoutParamsを持っていますが、どんなLayoutParamsがセットされるかは親のレイアウトによって変わります。
レイアウトパラメータの説明画像を見てもわかるように、親のレイアウトが LinearLayout
なら LinearLayout.LayoutParams
がセットされるし、親のレイアウトが ConstraintLayout
なら ConstraintLayout.LayoutParams
がセットされます。
例えば FrameLayout.LayoutParams
には android:layout_gravity
の属性がありますが、 RelativeLayout.LayoutParams
にはありません。
主に「2. ViewGroup.LayoutParamsを継承するレイアウトごとの属性があること」によって、レイアウトパラメータをStyleに書くべきではないと思っています。
例えば android:layout_gravity
は FrameLayout
内では有効な属性ですが、 RelativeLayout
内では無効な属性になってしまいます。
また、言い換えるとStyleにレイアウトパラメータを書くということは、使用できる親のレイアウトを制限していると言えるかもしれません。
そのため、複数の種類のレイアウトで使う想定があるなら、レイアウトパラメータをStyleに書くべきではないと思っています。
(一方で、レイアウトの種類ごとにStyleを作るという方針もありかもしれません)
android:layout_...
から始まるレイアウトパラメータは、親のレイアウトによって有効な属性が違う
本記事では、新卒1年目がグロースエンジニアを目指す中で学んだことを紹介します。
ただ私はまだグロースエンジニアには程遠いのであまり参考にならないと思いますが、「こんな失敗もあるのか〜」というお気持ちでご覧ください。
グロースエンジニアは、施策を実行する際に「分析・仕様策定からリリース・評価まで "一人で" 一貫してできるエンジニア」です。
下図は一般的な施策実行フローとグロースエンジニアの担当領域を示したものです。
図より、従来のエンジニアと比較して、グロースエンジニアの担当領域が広がっていることがわかります。
分析や仕様策定から担当するということは、自ずと「データ分析」「仮説立案」などのスキルも求められます。
では、これらのスキルから身につけていくべきなのでしょうか?
なぜかというと、私がいきなりデータ分析や仮説立案をやろうとした結果、身にならなかったためです。
ただ、これは私みたいにデータ分析や仮説立案を全くやったことがない方に言えることなので、データサイエンティストやビジネス職のスキルをお持ちの方には当てはまらないかと思います。
身にならなかった要因として以下のことを考えています。
1点目について、私がはじめに担当したタスクは仕様がすでに固まっていて実装から進められる状態でした。したがってメインタスクは実装なので、自ずとデータ分析や仮説立案をやってみる時間を作れませんでした。
2点目が大変重要なのですが、エンジニアの土台がないのにあれこれやろうとすると、今一番伸ばすべきエンジニアスキルがおざなりになってしまいます。また開発にジョインしたばかりなので、業務をしっかり遂行することこそが組織的には求められています。
もしあれこれやろうとして進んでいくと、エンジニアの土台がない、すなわち信頼がないので難しい実装タスクを任せてもらえなくなります。
グロース施策は難しかったり早さが求められたりするので、そもそもエンジニアとして信頼されていないとグロース施策にアサインされません。
前節で述べたように、新卒1年目の僕に求められているのは「エンジニアの土台づくり」です。
とはいえ、グロースに関する学びは継続したいところです。
そこで、私は以下のようなスタンスで学んでいこうと思っています。
1点目については、一人前のエンジニアになれるようにスキルアップするということです。私の場合は、設計力が弱いのでその力をつけていきます。
2点目のグロースハッキングマインドセットは、グロースエンジニアには必須のマインドセットのことです。詳しくは次節で説明します。
私は「グロース手法の1つで、グロースさせる仕組みをプロダクトに組み込むこと」だと理解しています。グロースハックの事例としては、無料クーポンを配布したり課金導線を改善したりするなど様々です。
「グロースハック」と「グロースハックの事例」については、それぞれ以下が参考になりました。
What is a Growth Hacking Mindset? (+ 5 tips to develop it)の中では、主に3点挙げてられていました。
- Always open for alternatives
- Data-driven decision making
- High digital intelligence
それぞれ翻訳すると以下のようになります。(※曖昧)
残念ながら体系だったスキルセット一覧は見つかりません。 ただ、1つの指針になるのが「Growth Hacker Skills in 2021: Technical, Analytical and Marketing Skills」という記事です。
記事ではグロースハッカーに必要なスキルが「Fundamental skills」「Generalist skills」「Specialist skills」の3つのレベルに分けれられています。
たくさんのスキルが必要で驚いたり迷ったりするかもしれませんが、駆け出しエンジニアがまず身につけるべきは「Technical Skills」です。つまるところ技術力のことで、Androidエンジニアの私にとってはAndroidアプリ開発スキルがそれに当たります。
次に身につけたいのは「Growth Hacking MindSet」です。記事にある図では「Technical Skills」の隣にあることがわかりますが、このように隣接するスキルを順番に身につけていくのが良さそうです。
本記事では、グロースエンジニアを目指して学んだことを紹介しました。
読んでくださった方に何よりお伝えしたいのは「グロースエンジニアは本当に"強い"エンジニアである」ということです。 グロースエンジニアに求められるスキルやレベル感がわかると「グロースエンジニアになりたい!」とは簡単に言えなくなるのでないでしょうか。
実際私も簡単に言えなくなりましたが、プロダクトを良くしたい・事業にもっとコミットしたいと思うならグロースエンジニアを目指していくべきだと思います。
これからもグロースエンジニアについて考えていきます!