Play Billing Library 5を試してみる
この記事はAndroid Advent Calendar 2022の18日目の記事です。
今回はPlay Billing Library 5 (PBL5)について色々試したので書いてみようと思います。特にOfferを設定した場合にどんな SubscriptionOfferDetails
が得られるのか気になっていたので調べました。
ひょっとしたら実行環境などによって挙動が変わる可能性がありますのでご了承くださいmm
雑なおさらい
Google I/O 2022で定期購入機能の変更が発表されました。
今までプランの名前、期間、金額、割引条件など1つにまとまっていた設定がSubscription, Base Plan, Offerというツリー構造に変わりました。
ツリー構造になることで1つのSubscriptionの下に複数のBase Plan(例: 月額プラン、年間プランなど)を設定できたり、1つのBase Planの下に複数のOfferを設定できるようになりました。
syarihuさんのDroidKaigiのセッションで詳しく解説されているので一度ご覧になることをお勧めします。
Play Billing Library 5
上の変更に対応したのがPBL5です。5.1.0が最新版です。
定期購入のアイテム情報を取得するにはqueryProductDetailsAsync) 関数またはktxライブラリの queryProductDetails 関数を使います。1
query すると ProductDetails やその中に含まれる SubscriptionOfferDetails や PricingPhases が得られます。
以下のサンプルコードのようにqueryした結果 SubscriptionOfferDetails
を探してきてその中の offerToken
を購入のパラメーターとして設定します。
class BillingSample { private val billingClient: BillingClient = TODO() fun launchPurchase(productId: String) { val products = listOf( QueryProductDetailsParams.Product.newBuilder() .setProductId(productId) .setProductType(BillingClient.ProductType.SUBS) .build() ) val params = QueryProductDetailsParams.newBuilder() .setProductList(products) .build() billingClient.queryProductDetailsAsync( params, this::handleQueryProductDetailsResult ) } /** * queryProductDetailsAsyncのコールバック関数 */ private fun handleQueryProductDetailsResult( result: BillingResult, productDetailsList: List<ProductDetails> ) { if (result.responseCode != BillingResponseCode.OK) { return } val productDetails = productDetailsList.firstOrNull() ?: return val subscriptionOfferDetails = productDetails.subscriptionOfferDetails ?: return val purchaseOfferToken = findSuitableOfferToken(subscriptionOfferDetails) ?: return // 購入フローに渡すパラメーターの構築 val productDetailsParamsList = listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(productDetails) .setOfferToken(purchaseOfferToken) .build() ) val billingFlowParams = BillingFlowParams.newBuilder() .setProductDetailsParamsList(productDetailsParamsList) .build() TODO("購入フローに渡す") } private fun findSuitableOfferToken( subscriptionOfferDetailsList: List<SubscriptionOfferDetails> ): String? { TODO("subscriptionOfferDetailsListから適したofferTokenを探して返す") // For example, please refer retrieveEligibleOffers and leastPricedOfferToken function in // https://github.com/android/play-billing-samples/blob/main/PlayBillingCodelab/finished/src/main/java/com/sample/subscriptionscodelab/ui/MainViewModel.kt } }
PBL5を試してみる
PBL5を試したい場合、Codelabを使うのが一番お手軽です。2
releaseビルドを作ってGoogle Play Consoleから内部テスト(internal-testing)にアップロードすれば一般公開しなくてもテストできます。
今回はこのアプリを使ってOfferの設定を変えながらどのようなsubscriptionOfferDetailsが得られるのか実験したいと思います。
このアプリではbasicとpremiumの2つのsubscriptionの下にそれぞれmonthly, yearly, prepaidの3つのbase planを設定しています。
また、subscriptionOfferDetailsの内容をlogcatで確認するために MainViewModel
の buy
関数にログを仕込みました。
試した結果
Offer無しの場合
Offerが設定されていない場合、 subscriptionOfferDetails
はBase Plan 1つにつき1つしかありません。
subscriptionOfferDetails = [ { basePlanId=monthlybasic, offerId=null, offerTags=[monthlybasic], offerToken=(deleted), pricingPhases=[...] }, { basePlanId=yearlybasic, offerId=null, offerTags=[yearlybasic], offerToken=(deleted), pricingPhases=[...] }, { basePlanId=prepaidbasic, offerId=null, offerTags=[prepaidbasic], offerToken=(deleted), pricingPhases=[...] } ]
初回購入限定のOfferを設定した場合
monthlybasicに初回購入限定でお試し期間1ヶ月のOffer(下の画像のmonthlybasic-freetrial)を設定してみます。
初回購入かどうかはGoogle Play側でチェックしているのでサービス側では特にチェックをする必要はないです。
初回購入時はbasePlanIdがmonthlybasicのsubscriptionOfferDetails
は2つ受け取れます。
片方は通常の購入、もう片方はお試し期間のOffer付きの購入です。offerIdやofferTagsを見ると区別がつくと思います。
subscriptionOfferDetails = [ ↓ここからmonthlybasic { basePlanId=monthlybasic, offerId=monthlybasic-freetrial, offerTags=[freetrial, monthlybasic], offerToken=(deleted), pricingPhases=[ { formattedPrice = Free, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 1, recurrenceMode = 2 }, { formattedPrice = ¥100, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1 } ] }, { basePlanId=monthlybasic, offerId=null, offerTags=[monthlybasic], offerToken=(deleted), pricingPhases=[ { formattedPrice = ¥100, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1 } ] }, ↑ここまで {basePlanId=yearlybasic, offerId=null, offerTags=[yearlybasic], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥1,000, priceCurrencyCode = JPY, billingPeriod = P1Y, billingCycleCount = 0, recurrenceMode = 1}]}, {basePlanId=prepaidbasic, offerId=null, offerTags=[prepaidbasic], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥120, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 3}]} ]
二回目の購入時は初回購入限定のOfferは渡されません
subscriptionOfferDetails = [ ↓ここからmonthlybasic { basePlanId=monthlybasic, offerId=null, offerTags=[monthlybasic], offerToken=(deleted), pricingPhases=[ { formattedPrice = ¥100, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1 } ] }, ↑ここまで {basePlanId=yearlybasic, offerId=null, offerTags=[yearlybasic], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥1,000, priceCurrencyCode = JPY, billingPeriod = P1Y, billingCycleCount = 0, recurrenceMode = 1}]}, {basePlanId=prepaidbasic, offerId=null, offerTags=[prepaidbasic], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥120, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 3}]} ]
デベロッパー指定のOfferを設定した場合
今度はmonthlypremiumにデベロッパー指定のOffer(下の画像のmonthlypremium-custom)を設定してみます。
デベロッパー指定の場合、サービス側でユーザーがこのOfferを適用できるかどうかチェックする必要があります。
初回購入時はmonthlypremiumのsubscriptionOfferDetails
は2つあります。
片方は通常の購入、もう片方はお試し期間のOffer付きの購入です。
subscriptionOfferDetails = [ ↓ここからmonthlypremium {basePlanId=monthlypremium, offerId=monthlypremium-custom, offerTags=[custom, monthlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = Free, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 1, recurrenceMode = 2}, {formattedPrice = ¥500, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1}]}, {basePlanId=monthlypremium, offerId=null, offerTags=[monthlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥500, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1}]}, ↑ここまで {basePlanId=yearlypremium, offerId=null, offerTags=[yearlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥5,000, priceCurrencyCode = JPY, billingPeriod = P1Y, billingCycleCount = 0, recurrenceMode = 1}]}, {basePlanId=prepaidpremium, offerId=null, offerTags=[prepaidpremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥550, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 3}]} ]
二回目の購入時も初回と同様にmonthlypremiumのsubscriptionOfferDetails
は2つあります。
subscriptionOfferDetails = [ ↓ここからmonthlypremium {basePlanId=monthlypremium, offerId=monthlypremium-custom, offerTags=[custom, monthlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = Free, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 1, recurrenceMode = 2}, {formattedPrice = ¥500, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1}]}, {basePlanId=monthlypremium, offerId=null, offerTags=[monthlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥500, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 1}]}, ↑ここまで {basePlanId=yearlypremium, offerId=null, offerTags=[yearlypremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥5,000, priceCurrencyCode = JPY, billingPeriod = P1Y, billingCycleCount = 0, recurrenceMode = 1}]}, {basePlanId=prepaidpremium, offerId=null, offerTags=[prepaidpremium], offerToken=(deleted), pricingPhases=[{formattedPrice = ¥550, priceCurrencyCode = JPY, billingPeriod = P1M, billingCycleCount = 0, recurrenceMode = 3}]} ]
まとめ
個人的には Offer (特にデベロッパー指定 Offer)を使うことでサービス側で柔軟に割引やお試しをユーザーに提供できるのに魅力を感じています。
一方で ProductDetailsの SubscriptionOfferDetails のリストの中から意図したもの(購入フローに渡したいもの)を選ぶロジックがどうしても複雑になりそう3なのがネックになっています。
個人的にはアプリ側ではなくサーバー側でロジックを実装してアプリ側はサーバー側から指定されたofferTokenをそのまま使うパターンがシンプルでいいかなと感じています。
余談
定期購入アイテムを登録するときにこれまで inappproducts
APIを使っていた場合は注意が必要です。
Google Play Consoleから編集しようとしてもread only状態になっていると思います。編集可能にするボタンがありますがクリックして編集可能にすると inappproducts
APIでは編集できなくなり、新しいSubscriptions Publishing APIを使わなければならない状態になります。
Googleの公式ドキュメントでも下のように記載されています。
Google Play Console で定期購入の編集を行うと、それ以降は inappproducts API を定期購入に使用できなくなります。 2022 年 5 月より前に Publishing API を使用している場合、問題を避けるために、既存の定期購入は Google Play Console ですべて読み取り専用として表示されます。変更しようとすると、この制限に関する警告が表示される場合があります。Google Play Console で定期購入を編集する場合は、新しい Subscription Publishing エンドポイントを使用できるよう、バックエンドの統合を事前に更新してください。
https://developer.android.com/google/play/billing/compatibility?hl=ja#managing-subscriptions
余談2
追記(2024-08-16): Google の中の人が見てくれたのか分かりませんが Play Billing Lab という便利なテストアプリが最近リリースされました。これを使えば下のめんどくささは解消できると思います!
初回限定Offerの挙動のテストは地味に面倒くさく、一度購入するたびに新しいProduct + Base Plan + Offerを作り直すか新しいGoogleアカウントを作る必要がありました。
この辺のテストが簡単になると大変ありがたいです...4
参考資料
ISUCON10に参加しました
ISUCONにチーム名「締切駆動開発」で初参加しました。 今までISUCON出たいなぁと思ってたもののチーム作るのが難しくて申し込みすらできなかったんですが今回は1人参加OKということでえいやっと申し込みました。
結果
言語はPHPで参加して最終スコアは589で惨敗でした。N+1問題の解決に時間がかかってDBのロードバランシングまで頭が回りませんでしたorz.. リポジトリは https://github.com/tsuyosh/isucon10q/ です
タイムライン
10時開始だったのが12時に延期になったので朝食を食べて洗濯しながら準備を進めていました。
12:20 競技が開始されたもののポータルページがBad Gatewayで死んでしまったので復旧を待ちながら事前に決めていたタスクを進めました。
余談ですが今回の真のお題はポータルページだったのでは、、と思うほど運営の方が大変そうでした。お疲れさまです。。 初参加なので以前のポータルページは知りませんがリーダーボードが時系列グラフ付きで見やすくなっていて、リクエストしたベンチマークもリアルタイムで状況が更新されて終わったらNotificationでプッシュ通知してくれるなど非常に便利でした。
sshの導通確認。当日マニュアルの内容を読んでssh configを変更。無事ssh繋がりました。
Host isucon-bastion HostName (踏み台サーバーのIP) Port 20340 User isucon TCPKeepAlive yes Host isucon-server1 ProxyJump isucon-bastion User isucon HostName 10.162.93.101 TCPKeepAlive yes LocalForward localhost:10080 localhost:80 Host isucon-server2 ProxyJump isucon-bastion User isucon HostName 10.162.93.102 TCPKeepAlive yes LocalForward localhost:20080 localhost:80 Host isucon-server3 ProxyJump isucon-bastion User isucon HostName 10.162.93.103 TCPKeepAlive yes LocalForward localhost:30080 localhost:80
各サーバーの設定をPHP実装に切り替え
当日マニュアルの確認をしながらAPIの実装をななめ読みする。
踏み台経由でブラウザからアクセスできるのでISUUMOを実際に使いながらAPIを確認。
ソースコードや設定ファイルをgithubにpush https://github.com/tsuyosh/isucon10q/commit/fc77bd929285c4af2370ec0b7650325d383f5391 https://github.com/tsuyosh/isucon10q/commit/e1f30519715bf205ca61bbf715ba61847ccd99f0 https://github.com/tsuyosh/isucon10q/commit/9652fcc398e23e94956e7f8e232b9a435d2ba269 https://github.com/tsuyosh/isucon10q/commit/edf9b7226973070e73c88044510f82ea0b173b1e
ISUUMOを使いながら問題になりそうな部分を付箋に書いていって簡易タスク管理をしました。(この辺は1人チームの楽なところ)
- なぞって検索のN+1問題
- 絞り込み項目が多い(price, width, height, depth, kindなど)
- features検索がLIKEを使った部分一致検索
- おすすめ物件の検索条件の改良
- 椅子を買うときのSELECT ~ FOR UPDATE部分の削除
- お買い得リスト(low_priced)APIの結果のキャッシュ
- 負荷分散
ポータルページが復旧したので早速初期ベンチを実行。初期値は330。他のチームは510前後だったのでいきなり差がついていた。。
13:57 計測できるようにMySQLのslow queryを出力 https://github.com/tsuyosh/isucon10q/commit/eab0200d33c4a91c3d8c232999edb122c8c2576e
ただ、SQLの条件文が絞り込み項目の数によって動的に変わるのでmysqldumpslowでボトルネックになりそうな部分を絞り込めませんでした。。
13:59 alpで解析できるようにnginxの設定を変更 https://github.com/tsuyosh/isucon10q/commit/53e0650f98174d4b2db8e6cecd0e9c526e65dd61
これもalpの使い方をあまり知らなかったため集計をいい感じにグループ化できなくてCOUNT=1の結果がズラーと並んでしまい活躍の機会がありませんでした。 終わった後でパターンマッチングでグループ化できるのを知りました。
16:00 とりあえずORDER BYでINDEXが使えるように複合INDEXを追加。Scoreが446に https://github.com/tsuyosh/isucon10q/commit/5ea37a3e36aa6d3d1c681e2885ea54f57d4de593
次になぞって検索のN+1問題に取り掛かる。 MySQLはGEOMETRY型にSpatial INDEXが使えるはずなのでカラムを追加してSQL文を変えれば解決するはずと思ってDB schemaの変更とINSERT, SELECT文の修正を行う。 https://github.com/tsuyosh/isucon10q/commit/6ec6ee4f418e665864907d8bbfa247fbf9e302c9 https://github.com/tsuyosh/isucon10q/commit/49673e754b8541ff98eaeb2c6445fb90b2b0e2a6
ただ、ここでベンチマークを実行してもFailになってしまい調べても原因がわからないので一旦revert。この時点で18:11なので2時間位格闘してたことになる。。 https://github.com/tsuyosh/isucon10q/commit/60feecf5381cd6c478ecb4029a5f20172a870887
18:12 椅子の購入時にstockをチェックするために SELECT ~ FOR UPDATE
をやってるのが無駄だったので UPDATE ~ WHETE stock > 0
のような感じでUPDATE文1つで済むように変更。ただ、思ったより効果はなくてスコアは437。
https://github.com/tsuyosh/isucon10q/commit/8acb3527d697703f5e39ca5e7f086b870f8a7935
18:41 price, width, height, depth, kindの絞り込み検索はいいアイディアが浮かばなかったのでやけくそで全部INDEXを貼ったものの案の定スコアは上がらず。 https://github.com/tsuyosh/isucon10q/commit/142b0014a45d44a759c3890009c9b5ca7cac5321
再度N+1問題に挑戦。今度はestateの初期データ部分(2_DummyChairData.sql
)は弄らず、ALTER TABLE文を追加で実行するSQL( 3_AddLocationToEstate.sql
)を追加。
https://github.com/tsuyosh/isucon10q/commit/d0b693c66a32c9f0fcab4695ca46f1cc27fd4555
最初はベンチマークFailに悩まされたもののログを追っていくと init.sh
以外でも initialize
APIでも実行するSQLファイルを追加する必要があるのがわかって速攻で追加。これでベンチが通ってスコアが589に伸びた。(最初に初期データのSQLを弄ったのが悔やまれる...)
https://github.com/tsuyosh/isucon10q/commit/4b6f6ba4374160d6900ee8d369f3d802d8148cf1
最後にBOT対策をやろうかと作業していたのだが時間が差し迫ってたので無理をせずこのままfinish
予選が終わっての感想
事前に色々準備をして1人でも回るようにしたかったのですがチーム名の如く準備は進まず結局ほぼすべての作業を手作業でやってました。
mysqlに接続するとかログを確認するとかgithubからサーバーにdeployする作業が地味にめんどくさかった。
あとssh接続が何も操作していないとハングって再接続しないといけなかったのが地味に辛かったです。ssh configで TCPKeepAlive yes
にしていたのですが。。
予選が終わった直後からDiscordのrandomチャネルで振り返りのコメントを見ていましたが知見に溢れていてずっと感心していました。DBを分割したらスコアが上がったという話を聞いてやっぱIOがボトルネックになってたんだなぁと思いました。あとfeatures検索の対処方法(bitsetとか全文検索とか)もなるほどなぁと思いました。
あとなぞって検索は皆さん苦労されてたようです。(ネタ元の)SUUMOでもすごく便利な機能なのでN+1問題は絶対解決したかったw 解決できてよかったww
今回は1人で参加しましたがよほど自信がない限りはチームを組んで役割分担したほうが良さそう。来年はチーム参加したいです。(本業がAndroidエンジニアなのでチームビルディングは大変ですが...)
参加者やスタッフの皆さんお疲れ様でした。
MacでNASのファイルを開くと変なファイルができるので直してみた
MacでNASのファイルを開くと._(開いたファイル名)
という名前のファイルができます。
ぐぐるとリソースフォークAppleDouble Header fileというmacOSのファイルシステム固有の仕様のようです。
しばらくは放っておいたのですが勝手にファイルを作られるとディレクトリのタイムスタンプが更新されてディレクトリをソートする際に大変不便だったので色々調べてみました。
macOSで無効化できないか?
結論を先にいうとできませんでした。.DS_Store
は下のコマンドで無効化できたんですけどAppleDouble Header fileの無効化はできませんでした。
defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool TRUE
NAS側でなんとかする
macOSでできないとなると後はNAS(Samba)側でなんとかするしかありません。 色々探してみるとmacOS向けの設定オプションがありました。
動作環境
前提条件として動作環境を載せときます。
リソースフォークを消す
最初に既にあるリソースフォークを消します。macOSにはdot_clean
という便利なコマンドがあるのでそれで消せます。
dot_clean /Volumes/(マウントしているNASの名前)
Sambaの設定変更
/etc/samba/smb.conf
に# For interoperability with macOS
以下の項目を追加しました。
[shared] path = /shared browseable = Yes read only = No # For interoperability with macOS vfs objects = catia fruit streams_xattr fruit:encoding = native streams_xattr:prefix = user. streams_xattr:store_stream_type = no
smb.confを編集したらsambaをリロードします
$ sudo systemctl reload smbd $ sudo systemctl reload nmbd
動作確認
試しにLinuxのコンソールからhogehoge.txtを作りました。 拡張属性はgetfattrコマンドで確認できますが作った直後は何もありません。
tsuyosh@enterprise:/shared$ touch hogehoge.txt tsuyosh@enterprise:/shared$ getfattr -d hogehoge.txt
Mac上から開くと何も変更を加えなくても下のように更新時間?が保存されます。
tsuyosh@enterprise:/shared$ getfattr -d hogehoge.txt # file: hogehoge.txt user.com.apple.lastuseddate#PS=0sDLILXQAAAACR38suAAAAAAA=
GW中に読んだ英語を記録していたので晒してみる
会社のEnglish Technical Writingのレッスンでゴールデンウィーク中に読んだり聞いたりした英語の文書を記録しようという課題があったのでどうせなのでblogで晒してみようと思います。 今回はbookmarkに積ん読していたblog記事が沢山あったので面白そうなのから読んでみました。JakeのAndroidのJava対応やD8/R8に関するシリーズは結構面白かったです(まだ途中だけど)。
読書時間は試験じゃないので読み飛ばすとかはしないけど思ったより長めだなぁという感想です。(Mediumに表示される読書時間よりはるかに長い) あと基本的な単語を使ってても意味がよくわからないphraseとかもあってもうちょっと語彙力改善したいなぁと感じました。
URL | 読書時間(時間:分) | よくわからなかった単語やフレーズ |
---|---|---|
https://android-developers.googleblog.com/2019/04/android-q-scoped-storage-best-practices.html | 00:14 | helping shape Android, elaborate change, Being devlopers ourselves, how we, can better align, retained, limit file clutter, community engagement |
https://source.android.com/security/app-sandbox | 00:16 | in this respect, break out of, one must compromise the security of the Linux kernel, enforce, defense-in-depth, over time, discretionary, per-physical-user boundary, confused deputy vulnerabilities, genuinely |
https://medium.com/@elye.project/debug-without-using-temporary-log-in-android-studio-91d9394a1e94 | 00:09 | relevant, according to your TAG, cheer up, With this in places |
https://jakewharton.com/androids-java-8-support/ | 01:01 | practically unfathomable, at the time of writing, over time, bespoke, having desugar occur, doesn’t contain any traces, a bit dense, and intimidating, reinforcing, dwindling, holding back, ahead of the game |
https://9to5google.com/2019/05/01/android-ui-framework/ | 00:19 | far more in-depth, more of, as to |
https://jakewharton.com/androids-java-9-10-11-and-12-support/ | 00:51 | table stakes , uptake, fares, As such, innocuous, over time, predates, As with |
https://jakewharton.com/avoiding-vendor-and-version-specific-vm-bugs/ | 00:39 | tip you off, lies with, happened close to home, exempt, dormant, admittedly, contrived, distilled into, conditionals, uncovered |
https://medium.com/androiddevelopers/coroutines-on-android-part-i-getting-the-background-3e0e54d20bb | 00:33 | Together, keep track, Give it a read |
https://medium.com/androiddevelopers/coroutines-on-android-part-ii-getting-started-3bff117176dd | 00:57 | tedious, error prone, linger, helps us out, reason about, obscure |
https://medium.com/@elizarov/null-is-your-friend-not-a-mistake-b63ff1751dd5 | 00:30 | witnessed, dreaded, plague, way before, off-guard, heinous, pollute, flaw, akin, whatsoever |
https://medium.com/netflix-techblog/android-rx-onerror-guidelines-e68e8dc7383f | 00:22 | As with, intuitive, divergent, outright, intermittent, forcibly, deterministic, moving off |
https://developer.squareup.com/blog/developing-on-ios-and-android/ | 00:10 | convergence, spartan, emerged, endeavor, surrogate |
drawable属性にcolorリソースが指定できるのがモヤッとしたので調べてみた
TL;DR
Context#getDrawable()
の引数にColorStateList以外のcolor resource id(e.g. R.color.hogehoge
)を渡すとColorDrawable
が返ってきます。
本文
なんかタイトルがモヤっとしててわかりにくいと思いますがきっかけは下のようなコードが動いたからです。
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@color/colorPrimaryDark" android:state_enabled="false"/> <item android:drawable="@color/colorAccent" android:state_pressed="true"/> <item android:drawable="@color/colorPrimary"/> </selector>
一見平凡なStateListDrawable
のdrawable resourceですがandroid:drawable
にcolorリソースを指定してもColorDrawable
として認識されます。
Drawable Resourceのリファレンスを確認してもReference to a drawable resource
としか書いてなくて一見するとdrawable resourceしか受け付けないように見えます。
ただ、Context.getDrawableのリファレンス)を見るとid
の定義がThe desired resource identifier, as generated by the aapt tool.
と書いていて若干曖昧な表現になってました。
というわけで以下のようなコードがどういう処理をしているかAOSPの実装を掘り下げていきました。
val drawable = getDrawable(R.drawable.state_list_demo)
読み進んで行くとResources#getDrawableForDensity(id:Int, density:Int, theme)
に行き着きます。
public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) { final TypedValue value = obtainTempTypedValue(); try { final ResourcesImpl impl = mResourcesImpl; impl.getValueForDensity(id, density, value, true); return impl.loadDrawable(this, value, id, density, theme); } finally { releaseTempTypedValue(value); } }
ResourcesImpl#getValueForDensity
も気になりますが*1、Drawable
を返しているのはloadDrawable
なのでこっちを読み進んでいきます。
コードが長いので端折りますが途中でこんなコードが見つかります。
final boolean isColorDrawable; final DrawableCache caches; final long key; if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { isColorDrawable = true; caches = mColorDrawableCache; key = value.data; } else { isColorDrawable = false; caches = mDrawableCache; key = (((long) value.assetCookie) << 32) | value.data; }
どうも引数で渡されたvalue
(TypedValue
)のtypeがTYPE_FIRST_COLOR_INT
からTYPE_LAST_COLOR_INT
の範囲ならisColorDrawable
がtrueになることがわかります。この辺を追いかければ答えが見つかりそうです。
少し読み進めていくとあっさり答えが見つかりました。
} else if (isColorDrawable) { dr = new ColorDrawable(value.data); } else {
つまり引数で渡したリソースがTypedColor.type
がColor関連ならばColorDrawable
を返すようです。
おまけ1
一応StateListDrawable
がどのように生成されるのかも確認してみました。やはり<item>
タグのandroid:drawable
属性も最終的にはResourcesImpl#loadDrawable()
を通るようです。
Context#getDrawable(id:Int) -> Resources#getDrawable(id) -> Resources#getDrawable(id, theme = null) -> Resources#getDrawableForDensity() -> ResourcesImpl#loadDrawable() -> ResourcesImpl#loadDrawableForCookie() -> Drawable.createFromXmlForDensity() ← xmlファイルのパース処理 -> Drawable.createFromXmlInnerForDensity() -> DrawableInflater#inflateFromXmlForDensity() ← xmlファイルの最初のタグ名に応じてDrawableインスタンスを生成 -> StateListDrawable#inflate() ← 残りのxmlのパースは生成されたDrawableに委譲 -> StateListDrawable#inflateChildElements() ← itemタグのパース -> TypedArray#getDrawable(index:Int) -> TypedArray#getDrawableForDensity(index, density = 0) -> Resources#loadDrawable() -> ResourcesImpl#loadDrawable() -> 以下続く
おまけ2
ちなみにStateListDrawable
と同じタグ(<selector>
)を使っているStateColorList
のリソースもこんな使い方ができるようですw
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello World!" android:textColor="@drawable/state_color_list_sample" /> <!-- res/drawable/state_color_list_sample.xml --> <?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="@color/colorPrimaryDark" android:state_enabled="false"/> <item android:color="@color/colorAccent" android:state_pressed="true"/> <item android:color="@color/colorPrimary"/> </selector>
追記1
Context.getDrawable()
にColorStateList
のリソースIDを渡すと見事に落ちました。
StateListDrawable
と勘違いしてパースに失敗するようです。
Caused by: org.xmlpull.v1.XmlPullParserException: Binary XML file line #3: <item> tag requires a 'drawable' attribute or child tag defining a drawable at android.graphics.drawable.StateListDrawable.inflateChildElements(StateListDrawable.java:190) at android.graphics.drawable.StateListDrawable.inflate(StateListDrawable.java:122) at android.graphics.drawable.DrawableInflater.inflateFromXmlForDensity(DrawableInflater.java:142) at android.graphics.drawable.Drawable.createFromXmlInnerForDensity(Drawable.java:1332) at android.graphics.drawable.Drawable.createFromXmlForDensity(Drawable.java:1291) at android.content.res.ResourcesImpl.loadDrawableForCookie(ResourcesImpl.java:833) at android.content.res.ResourcesImpl.loadDrawable(ResourcesImpl.java:631) at android.content.res.Resources.getDrawableForDensity(Resources.java:888) at android.content.res.Resources.getDrawable(Resources.java:827) at android.content.Context.getDrawable(Context.java:626) at androidx.core.content.ContextCompat.getDrawable(ContextCompat.java:463)
*1:コードをざっくり見たところ、指定したリソースIDの情報を取ってきてTypedValueにセットするメソッドのようです。
2018年を雑に振り返る
2017年に引き続いて雑に振り返りたいと思います。
移住&転職
去年の12月に会社を退職して福岡に移住するのを決めました。 前々から頭の中で燻っていたのですが勢い余ってやったというのが大きいです。 大きい決断には勢いが大事だと学びました。*1
(追記) 転職についてはひよこ大佐のツイートも励みになりました。面識もなんにもないですがひよこ大佐にはお礼を言いたいです。
4月30日付けで退職して2ヶ月弱は人生初の週休7日を体験しました。 失業者になったので失業手当を受けるべくハローワークにも通って説明会を受けたりPCをポチポチ触ったりしてました。*2
実際には3月末ぐらいに応募したLINE Fukuokaで内定を貰ったので6月15日から働き始めました。
家
家賃は安いです。前の住居(@渋谷)よりも広くて新築な上に安かったです。 まぁ通勤時間が徒歩10分から自転車30分に変わりましたが許容範囲内です。 博多とか天神付近に住みたい場合は1LDKで10万弱で住めそう。
食べ物
うどんは美味しいです。賛否両論ありそうですが牧のうどんは美味しかったです。*3 もつ鍋も結構あちこちにお店があります。ラーメンはとんこつより塩が好きなんですがShin Shinは好きです。 あと会社に週二で移動販売に来てくれるKen'sカレーは結構人気があるみたいです。
お賃金
おかげさまで増えました。地方都市でもAndroidエンジニアとして食っていけそうです。
会社では何してるの?
LINE Client(メッセンジャー)の1機能を担当しているチームに入りました。 普段はバグを修正したりPull Requestのレビューをしながら現在担当しているプロジェクトに取り組んでいます。
福岡のモバイルエンジニアは、LINEや関連サービスのiOS/Android clientを各地の拠点と連携し開発しています(写真はメッセンジャー担当チーム)。自分の手でLINEを良くしたい方、大規模ユーザアプリ開発に興味ある方、Swift/Kotlin好きな方お待ちしてます! #LINEのエンジニアhttps://t.co/2NBozG7aLi pic.twitter.com/TYiEx9RmcS
— LINE Developers (@LINE_DEV) December 13, 2018
開発環境
iMac(Core i7, 32GB)とMacbook Pro(Core i5, 16GB)と液晶モニターで普段開発してます。 オフィスについては941さんのBlogが詳しいです。
その他
通勤の途中で新二又瀬という交差点があるのですが、着陸する飛行機がすぐ上を通るので市街地の中に空港があるのが実感できます。
映画
37本くらい観てました。来年もだいたいこのくらいで推移しそう。 福岡に引越しして行きつけの映画館がTOHOシネマズからUnited Cinemasに変わりました。 TOHOシネマズのマイレージは4,000マイルくらい貯まってたんですが使わずに消滅しそう(´・ω・`)
面白かったのは
- スター・ウォーズ ハン・ソロ
- キングスマン ゴールデン・サークル
- パシフィック・リム アップライジング
- バーフバリ
- GODZILLA 決戦機動増殖都市 / 星を喰う者
- ミッション:インポッシブル/フォールアウト
- オーシャンズ8
- クレイジー・リッチ!
- 2001年宇宙の旅 IMAX
- ボヘミアン・ラプソディ
- ファンタスティック・ビーストと黒い魔法使いの誕生
IMAX版の2001年宇宙の旅を観に来たけど休憩(Intermission)がある。オリジナルと同じ仕様なのかな pic.twitter.com/UO1SQOJklM
— Tsuyoshi UEHARA (@uecchi) 2018年10月27日
アニメ
INGRESS THE ANIMATIONはまだ途中までしか見れてないorz
Ingress
4月7日のFukuoka XM Festivalに参加しました。ちょうど物件を探す予定と重なったので急遽参加できることになったんですが当日は寒いわ雹が降るわで薄着で来たのが大誤算でしたw あと7月28日のCassandra Prime Sapporo Anomalyにも参加しました。初札幌で久々のアノマリー参加でした。
来年は3月23日のDarsana Prime Tokyo Anomalyに行きたいですね。
進捗確認
結局できたのは最初の目標だけでしたorz
- ✅転職&移住
- ❎体重を落とす
- ❎DroidKaigiにCfPを出したい
- ❎ISUCONに参加したい
来年の目標
- 身体のメンテナンスをちゃんとしよう
- 体重を落とす
- 英語を喋れるようになる。具体的にはIELTS 6.5以上
- DroidKaigiにCfPを出したい
- ISUCONに参加したい
積み残しを少しづつ片付けていかないと...😅
振り返りは以上です。これで心置きなく年を越せますw 皆様良いお年を🎍
Kotlin/NativeでAndroidのNativeメソッドを実装してみた
この記事はAndroid Advent Calendar 2018の5日目21日目の記事です。
概要
普段C++/Cで実装しているAndroidのNativeメソッドをKotlin/Nativeを使って実装できないか試してみたというお話です。
サンプル
GitHubにリポジトリを上げました。 Android Studio 3.2.1上でビルドできるのを確認しました。
プロジェクトの構成は
app
: Androidプロジェクトgreeting
: Kotlin MultiPlatformプロジェクト。ここでJNI関数を実装
です。
Dynamic Libraryのビルド
ドキュメントを参考に設定できました。
fromPreset
関数の第一引数にpresets.androidNativeArm32
(64bit版をビルドしたい場合はpresets.androidNativeArm64
)を設定し、
ブロックの中でcompilations.main.outputKinds
をdynamic
に指定するとAndroid向けのsoファイルが生成できます。
plugins { id 'kotlin-multiplatform' } kotlin { targets { fromPreset(presets.androidNativeArm32, 'arm32') { compilations.main.outputKinds('dynamic') } } // (省略) }
JNI関数の定義
Kotlinの関数をトップレベルのC関数として定義するには以下のように@CName
アノテーションを利用します。
また、関数のシグネチャーは後述するPlatform Libraryの定義を参考にしました。
ちなみにJNIEnvVar
, jobject
, jstring
などはすべてPlatform Library(platform.android
パッケージ)に定義されています。
@CName("Java_io_github_tsuyosh_kotlinnativejni_Greeting_say") fun Java_io_github_tsuyosh_kotlinnativejni_Greeting_say( env: CPointer<JNIEnvVar>?, obj: jobject?, name: jstring? ): jstring? { // (省略) }
型の相互変換
INREROP.mdを読んで雰囲気で理解しただけですがこんな感じで相互変換しました。
jstring
とString
Kotlin/Nativeのサンプルコード等を見ながら変換用のユーティリティークラスを作りました。
(追記) 実はJNIのなんとかUTF関数が扱う文字コードはmodified UTF-8であることが発覚したので修正する予定ですorz
JNIのUTF-8って独自仕様だったの今頃知った時の顔をしている... https://t.co/qkZuGfh7Ku
— tsuyoshi uehara (@uecchi) December 27, 2018
jboolean
とBoolean
typealias jboolean = UByte
と定義されています。trueが1.toUByte()
でfalseが0.toUByte()
になります。
jint
とInt
typealias jint = Int
と定義されていますので特に変換処理はいらないです。
感想
C言語由来の型を扱うのに慣れるまで時間がかかる
INTEROP.mdを読むのが辛かった... この辺はいずれまとめたいと思います(忘れてなければ)
Platform Libraryのドキュメント少ない
ドキュメントを見ても概要しか書いていないのでどんなAPIがあるのか調べるのに苦労しました。。。
わかったことをまとめるとPlatform Libraryの定義が知りたい場合はkotlin-nativeの配布パッケージのklib/platform/<target>
以下を見て、
$ <KOTLIN_NATIVE_DIR>/bin/klib contents <KOTLIN_NATIVE_DIR>/klib/platform/android_arm32/android
の様に確認するのが手っ取り早いと思います。
(Platform Libraryはよく使われるライブラリをcinterop
を使わなくても扱えるようにしただけという感じなのかも)
まだIDEの補完機能が使えないクラスがあるのが辛い
kotlinx.cinterop
やplatform
以下のパッケージがまだコード補完できないし、型の定義を見ることもできません。
ひょっとしてCLionを使えばいいのかもしれないけどAndroid Studioでも対応して欲しい。。
Kotlin/Nativeのターゲットにandroid-x86がない
Androidエンジニアが普段使っているだろうx86ベースのエミュレーターでは確認できないので実機が必須です。 (armベースのエミュレーターは実用的じゃないので除外) android x86もターゲットに加えてほしい🙏
現状ではC++言語のライブラリが利用できない
公式サイトを見るとC++のライブラリを利用できない状態です。 JNIの組み込み関数もC言語方式の書き方で利用しないといけません。
On the other hand, Kotlin/Native supports interoperability to use existing libraries directly from Kotlin/Native: - static or dynamic C Libraries - C, Swift, and Objective-C frameworks
今後の展望
今回のサンプルはJava/Kotlinでも実装できる簡単なものでしたがcinterop
ツールを使えばC言語のライブラリを使うこともできると思います。
機会があればその辺も触って感想を書いてみたいと思います。
また、公開するシンボルをJNI関連に絞れなかったのでビルドオプションを直したいです。