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 やその中に含まれる SubscriptionOfferDetailsPricingPhases が得られます。

以下のサンプルコードのように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で確認するために MainViewModelbuy 関数にログを仕込みました

試した結果

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 という便利なテストアプリが最近リリースされました。これを使えば下のめんどくささは解消できると思います!

Test your Google Play Billing Library integration  |  Google Play's billing system  |  Android Developers

初回限定Offerの挙動のテストは地味に面倒くさく、一度購入するたびに新しいProduct + Base Plan + Offerを作り直すか新しいGoogleアカウントを作る必要がありました。

この辺のテストが簡単になると大変ありがたいです...4

参考資料


  1. 既存のquerySkuDetailsAsync関数も残っているのでそちらを使い続けることも可能ですがdeprecatedに指定されているのでいずれは移行する必要があります。
  2. ただし、Codelabの日本語版は微妙に内容が古いので英語版を使いましょう。githubのリンクが間違っていて小一時間費やしました...
  3. offerTagでマッチングをしたり最小の価格になるSubscriptionOfferDetailsを探したり実装のやり方は色々あります。
  4. 購入カウントをリセットができるようになるとか?

ISUCON10に参加しました

ISUCONにチーム名「締切駆動開発」で初参加しました。 今までISUCON出たいなぁと思ってたもののチーム作るのが難しくて申し込みすらできなかったんですが今回は1人参加OKということでえいやっと申し込みました。

結果

言語はPHPで参加して最終スコアは589で惨敗でした。N+1問題の解決に時間がかかってDBのロードバランシングまで頭が回りませんでしたorz.. リポジトリhttps://github.com/tsuyosh/isucon10q/ です

f:id:tsuyosh:20200913063140p:plain
ベンチマークの履歴

タイムライン

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のファイルを開くと変なファイルができるので直してみた

MacNASのファイルを開くと._(開いたファイル名)という名前のファイルができます。 ぐぐるリソースフォークAppleDouble Header fileというmacOSファイルシステム固有の仕様のようです。 しばらくは放っておいたのですが勝手にファイルを作られるとディレクトリのタイムスタンプが更新されてディレクトリをソートする際に大変不便だったので色々調べてみました。

macOSで無効化できないか?

結論を先にいうとできませんでした。.DS_Storeは下のコマンドで無効化できたんですけどAppleDouble Header fileの無効化はできませんでした。

defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool TRUE

NAS側でなんとかする

macOSでできないとなると後はNAS(Samba)側でなんとかするしかありません。 色々探してみるとmacOS向けの設定オプションがありました。

hatx.hatenablog.com

動作環境

前提条件として動作環境を載せときます。

リソースフォークを消す

最初に既にあるリソースフォークを消します。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のAndroidJava対応や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も気になりますが*1Drawableを返しているのは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年に引き続いて雑に振り返りたいと思います。

t-uehara.blogspot.com

移住&転職

去年の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のレビューをしながら現在担当しているプロジェクトに取り組んでいます。

開発環境

iMac(Core i7, 32GB)とMacbook Pro(Core i5, 16GB)と液晶モニターで普段開発してます。 オフィスについては941さんのBlogが詳しいです。

blog.kushii.net

その他

通勤の途中で新二又瀬という交差点があるのですが、着陸する飛行機がすぐ上を通るので市街地の中に空港があるのが実感できます。

映画

37本くらい観てました。来年もだいたいこのくらいで推移しそう。 福岡に引越しして行きつけの映画館がTOHOシネマズからUnited Cinemasに変わりました。 TOHOシネマズのマイレージは4,000マイルくらい貯まってたんですが使わずに消滅しそう(´・ω・`)

面白かったのは

2001年宇宙の旅IMAXシアターで観れてよかった。

アニメ

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 皆様良いお年を🎍

*1:もうちょっと決断が早くても良かったのではと反省している

*2:失業手当をもらうには求職活動のポイントを稼がないといけないのです...

*3: やわらかすぎて嫌だという意見も(n=1)

Kotlin/NativeでAndroidのNativeメソッドを実装してみた

この記事はAndroid Advent Calendar 20185日目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.outputKindsdynamicに指定すると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を読んで雰囲気で理解しただけですがこんな感じで相互変換しました。

jstringString

Kotlin/Nativeのサンプルコード等を見ながら変換用のユーティリティークラスを作りました。

(追記) 実はJNIのなんとかUTF関数が扱う文字コードはmodified UTF-8であることが発覚したので修正する予定ですorz

jbooleanBoolean

typealias jboolean = UByte

と定義されています。trueが1.toUByte()でfalseが0.toUByte()になります。

jintInt

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.cinteropplatform以下のパッケージがまだコード補完できないし、型の定義を見ることもできません。 ひょっとして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関連に絞れなかったのでビルドオプションを直したいです。