静的解析(Lint等)に頼らず、ViewModelに載ってる LiveData を ViewModel 外で更新させないようにすることを考える
これはまだプロダクションで動かしてないので気をつけてください
AAC ViewModel に生えた LiveData を外に出すとき、フィールドの重複宣言は面倒だし、だからといって直接 expose はしたくないし、それに MutableLiveData や MediatorLiveData を返してしまうと最悪キャストすれば更新できてしまう。
弊社で Android を触ってるのは今の所自分ひとりなので、とりあえず Mutable で expose して速度を優先しているのだけど、将来的にメンバーが増えたときとかはそうも言ってられない。
もちろんキャストとか ViewModel 外での更新はコードレビューで弾けるようなものなのだけど、「してはいけない」タイプの原則を人間が指摘しなければならないのは無駄だなーと個人的には思っている。また今回の問題は Lint で簡単に検出できる(caller の先祖要素に ViewModel がいないことを見ればいい)ので静的解析でチェックしてもいいんだけど、そうじゃない方法を考えてみたい。
と思ってたときに以下の記事が出てきた。
MutableなLiveDataを特定のクラス外から更新できなくする · stsnブログ
わかる と思ったのだけど、複数のパッケージに跨る LiveData と ViewModel とかも存在する可能性があって、その場合にはこの方法が使えないのでそこらへんを解決してる別の方法を考えてみた。(正直AACを絡めた設計はまだ考えているという段階で、将来的にそういうケースが出てくるかも?程度の可能性でしかない)
- APT
- Compiler plugin
とかも考えたけど、これは結局メンテできるひとがいなくなると黒魔術を越えた失われし秘術になってしまうので却下。Kotlin versionに合わせて動かなくなったら面倒くさいし。
ということでViewModel とその派生クラスからのみ更新するにはやはり可視性を protected にするという点を利用する。また Observable#hide
のように、別の実体に変換して対応する。
import android.app.Application import androidx.annotation.CallSuper import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer open class ViewModel(application: Application) : AndroidViewModel(application) { protected fun <T> LiveData<T>.mutate(): MutableLiveData<T> { return when (this) { is LiveDataWrapper -> this.wrappedLiveData is MutableLiveData -> this else -> throw IllegalArgumentException("the given live data is not wrapped. Please use liveData delegation at declarations.") } } protected inline fun <reified T> ViewModel.lazyLiveData(crossinline initializer: () -> LiveData<T>): Lazy<LiveData<T>> { return lazy { val liveData = initializer() if (liveData is MutableLiveData) { LiveDataWrapper(liveData) } else { liveData } } } protected inline fun <reified T> ViewModel.liveData(liveData: LiveData<T>): Lazy<LiveData<T>> { return lazyOf( if (liveData is MutableLiveData) { LiveDataWrapper(liveData) } else { liveData } ) } protected class LiveDataWrapper<T>(val wrappedLiveData: MutableLiveData<T>) : LiveData<T>() { private val observer: Observer<T> = Observer { super.setValue(it) } @CallSuper override fun onActive() { wrappedLiveData.observeForever(observer) } @CallSuper override fun onInactive() { wrappedLiveData.removeObserver(observer) } override fun postValue(value: T) { wrappedLiveData.postValue(value) } override fun setValue(value: T) { wrappedLiveData.value = value } } }
この ViewModel を使って、任意の ViewModel を構築していく。
class TestViewModel(application: Application) : ViewModel(application) { val exposedLiveData: LiveData<Int> by liveData { MutableLiveData<Int>() } fun testUpdate() { // compilation error : cannot access the function // exposedLiveData.value = 0 exposedLiveData.reify().setValue2(2) } } fun test(application: Application) { val vm = TestViewModel(application) vm.apply { // compilation error : cannot access the function // exposedLiveData.reify() // compilation error : cannot access the function // exposedLiveData.value = 0 // will crash exposedLiveData as MutableLiveData<*> } }
画面更新有りのサンプル
http://jmatsu.hatenablog.com/entry/2018/12/01/171740 · GitHub
こんな感じでやればいけそうな気がする。Reflectionによる更新はさすがに考えない。
ところで Kotlin の List/MutableList は JDK との都合上で仕方ないとして、LiveData/MutableLiveData のネーミングはどうにかならなかったのだろうか・・・
最初に貼ってたやつは wrapper を更新していて、何の意味もなかった。
package xxxx import android.app.Application import androidx.annotation.AnyThread import androidx.annotation.CallSuper import androidx.annotation.MainThread import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.Observer open class ViewModel(application: Application) : AndroidViewModel(application) { // ViewModel クラス内でのみ Wrapper クラスを参照できるようにする protected fun <T> LiveData<T>.reify(): LiveDataWrapper<T> { return requireNotNull(this as? LiveDataWrapper) { "the given live data is not wrapped. Please use liveData delegation at declarations." } } // LiveData を wrap する delegation を用意する protected inline fun <reified T> ViewModel.lazyLiveData(crossinline initializer: () -> LiveData<T>): Lazy<LiveData<T>> { return lazy { LiveDataWrapper(initializer()) } } protected inline fun <reified T> ViewModel.liveData(liveData: LiveData<T>): Lazy<LiveData<T>> { return lazyOf(LiveDataWrapper(liveData)) } // LiveData を wrap して、MutableLiveData 及びその派生を直接 expose しない。 protected class LiveDataWrapper<T>(private val liveData: LiveData<T>) : LiveData<T>() { private val observer: Observer<T> = Observer { setValue2(it) } @CallSuper override fun onActive() { liveData.observeForever(observer) } @CallSuper override fun onInactive() { liveData.removeObserver(observer) } // Reflection での更新を防止しておく。 // KotlinのDeprecatedを使って、誤った利用を避ける。 @Deprecated( message = "use postValue2 instead. this method always cause a crash", replaceWith = ReplaceWith( "postValue2(value)", imports = ["xxxx.ViewModel.LiveDataWrapper.postValue2"] ), level = DeprecationLevel.ERROR ) override fun postValue(value: T) { throw IllegalAccessError("cannot be invoked even through reflection") } @Deprecated( message = "use setValue2 instead. this method always cause a crash", replaceWith = ReplaceWith( "setValue2(value)", imports = ["xxxx.ViewModel.LiveDataWrapper.setValue2"] ), level = DeprecationLevel.ERROR ) override fun setValue(value: T) { throw IllegalAccessError("cannot be invoked even through reflection") } // 更新用のメソッドを新たに生やしておく @AnyThread fun postValue2(value: T) { super.postValue(value) } @MainThread fun setValue2(value: T) { super.setValue(value) } } }
Kotlinの拡張関数を使って、Cursor#use すると ClassCastException で落ちる
現象
Fatal Exception: java.lang.ClassCastException: android.content.ContentResolver$CursorWrapperInner cannot be cast to java.io.Closeable
原因
Cursor が Closeable を継承するのは API 16 から。ContentResolver の返す Cursor が custom cursor にできない問題に気を取られすぎて、Closeable を継承してないのを忘れていた・・・
明示的に Cursor を Closeable にすると、ちゃんと Android Lint が怒ってくれる。use を使う場合は出てこない。悲しみが深い。
解決策
以下の拡張関数をcompatで書いて対応。関数名をuseにしていないのは書いてるときにimportする関数を間違える可能性が高いから。
import android.database.Cursor import android.os.Build import kotlin.io.use as ioUse inline fun <T : Cursor?, R> T.useCompat(block: (T) -> R): R { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { ioUse(block) } else { var exception: Throwable? = null try { return block(this) } catch (expected: Throwable) { exception = expected throw expected } finally { when { this == null -> { } exception == null -> close() else -> try { close() } catch (ignored: Throwable) { } } } } }
理想的な解決策
minimum support API を28にする
Android app の versioning と naming
弊社ァでは今までリリースごとに1つずつバージョンを上げていたけれど、以下の問題点が存在した。
- meaninglessなバージョンなので「それはアプリのx.y.zバージョンだとどれなのか」が分かりづらい。 e.g. SDK とのやり取り
- Internal Trackテスト版ではログが有効であるなど、そのアプリをそのままリリースはできない。したがって最終的なリリース版は内部テストの回数だけ番号が上がってしまう。どのapkがproduction apkが分かりづらく、↑の問題をさらに加速させる。
そこで versioning と naming をうまくやることで上記の問題点を解決することにした。
- CI でしかリリース署名apkは作らない
release
ブランチの成果物を production artifacts としているinternal/xxx
ブランチの成果物は Internal Track で使う artifacts
として、以下の記述にしている。
ext { isForInternalTrack = System.getenv("CI") == "true" && System.getenv("CIRCLE_BRANCH")?.startsWith("internal/") isReleaseCandidate = !isForInternalTrack && System.getenv("CI") == "true" && System.getenv("CIRCLE_BRANCH") == "release" } android { defaultConfig { applicationId ... def versions = [ "major" : 1, "minor" : 0, "patch" : 0, "internal": 0 ] assert 0 <= versions["major"] && versions["major"] <= 99 assert 0 <= versions["minor"] && versions["minor"] <= 99 assert 0 <= versions["patch"] && versions["patch"] <= 99 assert 0 <= versions["internal"] && versions["internal"] <= 99 versionName versions["major"] + "." + versions["minor"] + "." + versions["patch"] if (!isReleaseCandidate) { versionNameSuffix "-${hashOrCINum}-non-production" } if (isForInternalTrack) { versionNameSuffix = (versionNameSuffix + "-internal-track") } // max value which GooglePlay allows is 2,100,000,000. versionCode 1_000_000 * versions["major"] + 10_000 * versions["minor"] + 100 * versions["patch"] + versions["internal"] ... } }
リリース自体を自動化してもっとシュッとしたい(小並感)
エミュレーターでプリインアプリを再現する
今回は API 27 でお試し。
- AVD name を確認しておく
ls ~/.android/avd/
cd $ANDROID_SDK_HOME/tools export PATH=$PWD:$PATH # tools 以下にいないと以下のコマンドは失敗する # -writable-system で起動しないと/systemに書き込み権限がない emulator -avd $avd_name -writable-system # note: avd_name に拡張子はいらない # Google APIが入ってるとproduction扱いなので、adbdがrootで立ち上がらない adb root adb remount adb shell # このディレクトリにプリインが入る。古いバージョンだとディレクトリが違う。 cd /system/priv-app mkdir $package_name exit adb push $apk_file /system/priv-app/$package_name/$apk_file # 再起動するまではインストールされない adb reboot
昔とsystem以下への書き込み権限の取得方法が違ったのでちょっとだけハマった
CircleCI の特定ブランチの特定jobのアーティファクトをダウンロードする
はい
#!/usr/bin/env bash set -o pipefail set -eu export PRODUCTION_BRANCH="release" export JOB_NAME="build" latest_artifacts() { local -r build_num=$(curl -u "$CIRCLECI_TOKEN:" "https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/tree/$PRODUCTION_BRANCH?filter=completed" | \ ruby -rjson -e 'puts JSON.parse(STDIN.read).find { |j| j["build_parameters"]["CIRCLE_JOB"] == ENV.fetch("JOB_NAME") }["build_num"]') curl -u "$CIRCLECI_TOKEN:" "https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/$build_num/artifacts" } get_asset_url() { cat - | ruby -rjson -e 'puts JSON.parse(STDIN.read).select { |j| <a filter like `j["url"].include?(".apk")`> }.first["url"]' } download_asset() { local -r asset_url=$(latest_artifacts | get_asset_url) curl -o "$1" "$asset_url?circle-token=$CIRCLECI_TOKEN" } download_asset "$1"
GETでbranchを絞る方法はAPI Docになかったけれど、POSTの方法を参考にしたらちゃんとfilterされた。今後動かなくなるかもしれない。
GithubのLatest Releaseからassetをダウンロードするスクリプト
はい
#!/usr/bin/env bash set -o pipefail set -eu GITHUB_USERNAME="..." GITHUB_REPONAME="..." GITHUB_API_TOKEN_USERNAME="... : "${GITHUB_API_TOKEN:=$DANGER_GITHUB_API_TOKEN}" latest_gh_release() { curl -#L -H "Authorization: token $GITHUB_API_TOKEN" "https://api.github.com/repos/$GITHUB_USERNAME/$GITHUB_REPONAME/releases/latest" } get_asset_url() { cat - | ruby -rjson -e 'puts JSON.parse(STDIN.read)["assets"].select { |j| <<the filter like `j["name"].end_with?(".apk")` %>> }.first["url"]' } download_asset() { local -r asset_url=$(latest_gh_release | get_asset_url) curl -#Lo "$1" -u "$GITHUB_API_TOKEN_USERNAME:$GITHUB_API_TOKEN" -H 'Accept: application/octet-stream' "$asset_url" } download_asset "$1"
Let's Improve Your and Co-workers' DX ~ CI Improvement Tips ~
そもそもの背景として、「自分や同僚のDX (Developer Experience) を上げる」というテーマで LT をしようとしていました。
そこで CI を設定、保守・運用するにあたって便利な Git、GitHub 周りの Tips と 具体的な CI として CircleCI の Tips を取り上げたんですが・・・
書いていたらスライドが30枚くらいになってしまい、これは15分でも終わるか分からないしそんな勉強会ないしなーということでブログ記事に起こします。Marpで書いていたスライドをそのまま貼っているので、見やすいかどうかは分からないです。例えばURLの生成はスライド幅の都合上、無理やり配列を使っていますが、通常であれば文字列宣言で終わります。
現職、前職でも参照できるように英語で資料を使っていましたが、まだ推敲前だった 1 のでかなり適当です。
スライド版は以下。
Let's improve your and co-workers' DX ~ CI improvement tips ~ - Speaker Deck
アジェンダ
Git Tips
- Commit-aware
- Know if HEAD is a merge-commit
- Get the latest merge commit
- Get a branch ref of a merge-commit
- How can we know the last merged branch?
- Change-aware
- Know if a directory/file has been changed
- What changes happned between merges?
- Commit-aware
Github Tips for CI
- Get the base branch of your PR
- Know if directories/files have been changed in the PR
- Create a PR from CI
CircleCI Tips
- General tips
- md5 hash of multiple files
- Download the latest artifact from the specific branch
- Go with better caches!
- Use version for cache keys
- Use environment variables for cache key versions
- For your good development
- Don't need to source your utilities multiple time
- Use {{ .Revision }} or {{ .BuildNum }} when spinning up CI environment
- General tips
Let's Improve Your and Co-workers' DX
~ CI Improvement Tips ~
Who?
- Jumpei Matsuda (@jmatsu, @red_fat_daruma)
- Android/Backend Engineer at DeployGate Inc.
DX - Developer Experience
General speaking, there are several definitions:
- User Experience for Developers
- Quality of Your Developer Life etc...
What this presentation uses
- Quality of Your Developer Life {on CI, when modifying CI conf}
Why should we improve DX?
OBVIOUS, right?
Why don't you improve your DX?
Enough? Your company is awesome!
Please recruit me to join
Git Tips for CI
It's better to know two approaches!
- Commit-aware
- Know if HEAD is a merge-commit
- Get the latest merge commit
- Get a branch ref of a merge-commit
- How can we know the last merged branch?
- Change-aware
- Know if a directory/file has been changed
- What changes happned between merges?
Commit-aware Git tips
Know if HEAD is a merge-commit
[[ -n "$(git show --merges HEAD -q)" ]]
--merges
only merge commits will be shown-q
suppress diff part
So you can infer the job trigger! e.g. merge
or direct push
allow_deploy() { # Never allow deployment if pushed directly! [[ ! -n "(git show --merges HEAD -q)" ]] }
Know sha1 of the latest merge-commit
git show --merges -1 -q --format='%h'
--format='%h'
only sha1 hash will be shown
Not useful if you will be using only this tip :)
Get a branch ref of a merge-commit
# origin/foo/bar remote_slash_branch=$( git show $sha1 --merges --format='%s' -q | \ awk '$0=$NF' ) # origin/foo/bar => foo/bar, origin/foo => foo echo $remote_slash_branch | sed 's/^[^\/]*//'
--format='%s'
only subject will be shown- The message format must be like
.... $REMOTE/$BRANCH_NAME
- Omit the sed command if you wanan use this for local merge
How can we know the last merged branch?
- Combine these tips!
- Know sha1 of the latest merge-commit
- Get a branch ref of a merge-commit
hash=$(git show --merges -1 -q --format='%h') remote_slash_branch=$( git show $hash --merges --format='%s' -q | \ awk '$0=$NF' )
Change-aware Git tips
Was the file/directory changed?
# all workspace [[ -n $(git diff ${ref}) ]] # 'foo' file/directory [[ -n $(git diff ${ref} -- 'foo') ]] # .kt files under 'bar' directory git diff ${ref} --name-only -- 'bar' | \ grep "*.kt" >/dev/null 2>&1 # submodule change [[ -n $(git diff ${ref} --diff-filter=T) ]]
What changes happned between merges?
l=$(git show --merges -q -1 --format=%h) s=$(git show --merges -q -2 --format=%h) # Show all files which have been changed between them git diff $s...$l --name-only # For example, # If app's version has been changed, if [[ -n "$(git diff $s...$l -- "app/version")" ]]; then # This should fail if REAMDE is not updated [[ -n "$(git diff $s...$l -- "README.md")" ]] || exit 1 fi
Not yet enough...
Let's see Github Tips and CircleCI Tips!
Github Tips for CI
- Get the base branch of your PR
- Know if directories/files have been changed in the PR
- Create a PR from CI
Get the base branch of your PR
paths=( "https://api.github.com/repos" "/$CIRCLE_PROJECT_USERNAME" "/$CIRCLE_PROJECT_REPONAME/pulls" "/$(basename $CIRCLE_PULL_REQUEST)" ) URL="$(echo ${paths[*]} | tr -d " ")" curl -H "Authorization: token $GH_TOKEN" \ "$URL" | jq '.base.ref' # ruby -rjson -ne 'puts JSON.parse($_)["base"]["ref"]'
- Don't fetch all remote refs because the cost is high.
Was the file/directory changed in the PR?
- Combine these tips!
Was the file/directory changed? from Git Tips
Get the base branch of your PR
base_branch_name=$(...) # make sure fetch the base branch. # Usually CI doesn't have it on local git fetch origin $base_branch_name # Use Git Tips! e.g. git diff $base_branch_name...HEAD -- ...
Create a PR from CI
paths=( "https://api.github.com/repos" "/$CIRCLE_PROJECT_USERNAME" "/$CIRCLE_PROJECT_REPONAME/pulls" ) URL="$(echo ${paths[*]} | tr -d " ")" CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" fields=("\"head\": \"$CURRENT_BRANCH\"", \ "\"base\": \"<base branch>\"", \ "\"title\": \"<PR title>\"") JSON_BODY="{${fields[*]}}" curl -s -H "Authorization: token $GH_TOKEN" \ -H "Content-Type: application/json" \ -d "$JSON_BODY" "$URL"
- Bash Script can create a PR! Don't need to use other languages!
CircleCI Tips
- General tips
- md5 hash of multiple files
- Download the latest artifact from the specific branch
- Go with better caches!
- Use version for cache keys
- Use environment variables for cache key versions
- For your good development
- Don't need to source your utilities multiple time
- Use {{ .Revision }} or {{ .BuildNum }} when spinning up CI environment
md5 hash of multiple files
{{ checksum "a" }}-{{ checksum "b" }}-{{ checksum "c" }}-.... 😫
Get md5 hash of a file which contains the output of the following command
# For examaple, multi-module project while read target_file; do md5sum $target_file done < <(find . -name "build.gradle" | sort) # ↑ sort is important # `find` might be more complicated in some use-cases
Download the latest artifact from the specific branch
EDITED cuz the previous implementation was wrong
latest_artifacts() { local -r build_num=$(curl -u "$CIRCLECI_TOKEN:" "https://circleci.com/api/v1.1/project/github/DeployGate/deploygate-android?filter=completed" | \ ruby -rjson -e 'puts JSON.parse(STDIN.read).find { |j| j["branch"] == ENV.fetch['PRODUCTION_BRANCH'] && j["build_parameters"]["CIRCLE_JOB"] == "build" }["build_num"]') curl -u "$CIRCLECI_TOKEN:" "https://circleci.com/api/v1.1/project/github/DeployGate/deploygate-android/$build_num/artifacts" } get_url() { cat - | jq -r ".[].url" | grep "<use regexp>" | head -1 } URL=$(latest_artifacts | get_url) curl -o "<output>" "$URL?circle-token=$CIRCLECI_TOKEN"
Use version for cache keys
It would be your help when you want to refresh caches.
- e.g. when configuring CI, when introducing new key parts
- v1-danger-{{ checksum "~/Gemfile.lock" }} - v2-gradle-...
Cache key versions matter
- You need to upgrade all cache versions of save/restore sections
- Sometimes we forget to modify keys... 😩
- If we can use environment variables for cache keys, this can be solved...
- However,
{{ .Environment.variableName }}
doesn't mean we can use any arbitrary variables... So let's use workaround!
Use environment variables for cache key versions
cache_version_keys: &cache_version_keys CACHE_VERSION_OF_DANGER: v1 environment: <<: *cache_version_keys run: | while read temp; do var_name="$(echo $temp | awk -F= '$0=$1')" echo "$temp" | sed "s/$var_name=//" > ~/$var_name done < <(env | grep "CACHE_VERSION_OF_") # keys - {{ checksum "~/CACHE_VERSION_OF_DANGER" }}-danger-...
You need to change only one variable when bumping up the version!
Don't need to source utils multiple times
BASH_ENV is a entry point to be loaded before running each steps.
run: echo 'source <your util file>' >> $BASH_ENV run: echo 'export XXXX=YYYYY' >> $BASH_ENV
- If you are using custom images which don't extend CircleCI's image, then this tips might not work for you.
Use {{ .Revision }} or {{ .BuildNum }} when spinning up CI environment
I guess some of you have written wrong caches by the correct key when configuring CI environment, right?
# Use this share caches between multiple jobs - v1-foo-bar-{{ .Revision }} # Use one-time cache key for the debug perpose - v1-foo-bar-{{ .BuildNum }} # Use the key when restoring caches if cached 💯
After debugging, please remove {{ .Revision }}
and {{ .BuildNum}}
, and also bump up version of cache key, v1
to v2
.
BTW
DroidKaigi 2019
- 2019 Feb 7, 8
- Shinjuku, Tokyo, Japan
- https://droidkaigi.jp/2019/
Please join us and enjoy :)
-
ただし推敲したからといってしっかりとした英語が書けるわけではないので大した話ではないです。↩