- 投稿日:2020-11-16T23:21:28+09:00
Github ActionsでDeployGateにデプロイする話
はじめに
今回はGithub Actionsを使ってDeployGateに自動デプロイする方法を紹介していきます。
CI/CDってなんかかっこいいですよね(小並感)
Androidの方法なのでiOSの方には参考にならないかもしれませんmm
準備
まずは準備していきましょう
まずはDeployGateを開きます。
そしたら右上のアイコンのところからアカウント設定を選び、下の方にあるAPI Keyを確認します。(後で使うのでメモっといてください)
次にGithubのレポジトリにsecretsを追加していきましょう。
レポジトリのタブバーからSettingを選択
左のメニューからSecretsを選び、先ほどのAPIキーとDeployGateのユーザー名を追加します。
いい感じ
準備終わり!
デプロイのタイミング
今回はプルリクエストベースでプロジェクトが進んでいたので、レビューしてもらってApproveをもらい、マージされたタイミングでデプロイするようにしています。
PR出す→レビュー(通らなかったら修正)→Approveをもらったらマージ→デプロイが走る
やり方
まずはプロジェクトのルートに.github/workflowsとディレクトリを作ります。
.github/workflowsの中に適当な名前でyamlファイルを作っていきましょう。サンプルでdeploy.ymlと名付けました
Github Actionsのトリガーを見てみるとclosedのタイミングはあるけど、マージされたタイミングというのは実はないのです。
クローズされたタイミングで毎回走らせてしまうと、間違えてPR出してクローズした際もデプロイされてしまいます。それは不便ですよね。
なのでマージされたタイミングをWebHookで取得します。
プルリクエストのWebHookのペイロードを見てみると、mergedというプロパティがあるのがわかりますね。
これがtrueになればマージされているということなので、Github Actionsのトリガーと合わせて使えそうです。あとはワークフローを書いていくだけ!
全体はこんな感じ
deploy.ymlname: Deploy CI on: pull_request: types: [closed] branches: [master] jobs: build: if: github.event.pull_request.merged == true runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v1 - name: Set up JDK 1.8 uses: actions/setup-java@v1 with: java-version: 1.8 - name: Build App Bundle run: ./gradlew assembleRelease - name: Distribute App run: | curl \ -H "Authorization: token ${{secrets.DEPLOY_GATE_API_KEY}}" \ -F "file=@app/build/outputs/apk/release/app-release.apk" \ -F "message=${{github.event.pull_request.title}}" \ -v "https://deploygate.com/api/users/${{secrets.DEPLOY_GATE_USER_NAME}}/apps"pull_requestをトリガーとして、typesでアクティビティタイプを指定(今回はマージ(クローズ)したタイミングなのでclosed)、branchesでどのブランチにpull_requestを出した時から指定します。
ちなみアクティビティタイプに関してはオープンしたタイミング、アサインされたタイミング、ラベルをつけたタイミング等いっぱいあるので遊んでみてください。
on: pull_request: types: [closed] branches: [master]ここからjobの開始
ifでマージされたタイミングだけ走らせるように指定します。jobs: build: if: github.event.pull_request.merged == trueちなみにこの
github.event.pull_request.mergedがWebHookのpayloadの取得です。
これもトリガーの一つだと思ってたんですが、WebHookの取得だったんですね、最近知りましたこれはAndroidのビルドをする環境を用意
steps: - uses: actions/checkout@v1 - name: Set up JDK 1.8 uses: actions/setup-java@v1 with: java-version: 1.8これでapkの作成
- name: Build App Bundle run: ./gradlew assembleDebugここが今回の本題
- name: Distribute App run: | curl \ -H "Authorization: token ${{secrets.DEPLOY_GATE_API_KEY}}" \ -F "file=@app/build/outputs/apk/release/app-release.apk" \ -F "message=${{github.event.pull_request.title}}" \ -v "https://deploygate.com/api/users/${{secrets.DEPLOY_GATE_USER_NAME}}/apps"curlコマンドでDeployGateのAPIを叩いていきます。
ヘッダーに先ほどDeployGateからメモったAPI Keyを追加します。
Secretsに入れた値は${{ secrets.hogehoge}}
で取得できます。便利ですね
あとはfileにapkを指定。
-F "file=@app/build/outputs/apk/release/app-release.apk" \
DeployGateの更新をする際のメッセージはプルリクエストのタイトルを使うことにします。
こちらもWebHookのpayloadから取得-F "message=${{github.event.pull_request.title}}" \
最後にapiのURLを指定するだけ
ちなみにDeployGateのデプロイ先はapplicationIdで自動で変わってくれるので、特に指定はいりません。
なので環境ごと(develop, staging, production)のサフィックスを入れておけば、それぞれ違うデプロイ先になってくれます。-v "https://deploygate.com/api/users/${{secrets.DEPLOY_GATE_USER_NAME}}/apps"
まとめ
やっぱり自動化ってとにかく楽でいいですね。
ビルドしてapkの作成を待って、DeployGateにドラッグ&ドロップ
大規模アプリの場合はビルドだけでもかなり時間が取られますし、何よりヒューマンエラーがなくなります。(環境を変え忘れるなど)現在色んなことを自動化しているのでどんどん紹介していきたいと思います。(どんなことをしているかはTwitterを見ればなんとなくわかるかも?)
P.S.
筆者はcurlコマンド滅多に使わないので、かなり勉強になりました。
複数行curlコマンドを書いていくときに、バックスラッシュを忘れて上手く動かず、なんでや!!となっていたのはいい思い出
- 投稿日:2020-11-16T23:18:20+09:00
初学者向けKotlin Coroutines Flow(勉強会用スライド)
前置き
- 本資料は以下の方向けです
- Flowって何だ、うめぇのか?
- コルーチン??
- 周りでFlow使われてるけど、サッパリわからん…
- 1日でFlowを何となく理解したい
- 以下の方は退屈かと思います
- Flowを何となく理解した
- Flowはいいぞ
- Flow?あぁ、若い頃を思い出すな…
Kotlin Coroutine Flowとは?
- Kotlin Coroutinesの新しい非同期処理用ライブラリ
- RxやPromiseに似た記述ができる
- コールドストリーム!
コールドストリーム??
一応ちゃんとした説明
- Subscribeされたら初めて動きだす、Observableなストリーム
- ストリーム...データを連続して送り出す型
- それ以上は、RxのHotとColdについてを参照
ピンとこないので、何かに例えよう
普段、仕事の成果を「アウトプット」と呼ぶので…
この状況をイメージ
- 上司(Subscriber)が社員(Observable)を監視(Subscribe)する
←語呂がいい- 社員は上司に状況を逐一アウトプット(emit/send/offer)する
- 上司はそのうち監視をやめる(Dispose)
ホットストリームな働き方
?? Hotさん「さあ、上司いないけど仕事はじめようっと!」
※ 上司に監視されなくても働き始める
? 上司「Hotさん、今どんな感じ?」
?? Hotさん「はい、こういう状況です!」
? 上司「・・・なるほど」?? Hotさん「状況変わりました!」
? 上司「・・・なるほど」※ 上司が見てる間、ずっと仕事のアウトプットを出す
? 上司「私は帰るよ」
?? Hotさん「では、作業終了します!」※ 上司に止められるまで働き続ける
コールドストリームな働き方
?? Coldさん「・・・(ボーッとしている)」
※ 上司に見られてないと働かない
? 上司「Coldさん、どんな感じ?」
?? Coldさん「(やべ、上司だ!)今はじめますんで!…はい、こういう状況です」
? 上司「・・・なるほど」?? Coldさん「状況変わりました!」
? 上司「・・・なるほど」※ 上司が見てる間、ずっと仕事のアウトプットを出す
?? Coldさん「(上司いなくなったな…)作業終了します」
※ 上司がいなくなったら自分で働くのをやめる
Hotさんのほうがまじめでがんばりやに見えるが…
Hotさんの良いところと欠点
- 良いところ
- 上司がいなくても働ける
- 上司が増えても、使うリソースは変わらない
- 欠点
- ちゃんと止めてあげないと、必要ないときも働き続けてしまう
Coldさんの良いところと欠点
- 良いところ
- 必要ないときに働かない
- 欠点
- 上司が2人に増えたら、使うリソースも2倍に増えてしまう
Coldさんのほうがありがたい場合もある(主にメモリリーク防止)
元々KotlinにはHotさんがいたが、Coldさんは最近入社された
HotさんはChannel、ColdさんはFlow
なんとなくイメージ掴んだところで
Flowの基本的な使い方
- ビジネスロジックは無加工のデータを非同期に送る
CounterUseCase.ktsuspend fun countStream(): Flow<Int> = flow { repeat(100) { count -> delay(100) emit(count) // 0 1 2 3 4 ... 99 } }
- ViewModelがデータを受け取り、表示向けに加工する
CounterViewModel.ktval count = MutableLiveData<String>() fun counter() { viewModelScope.launch(Dispatchers.Main) { useCase.countStream() .drop(1) .filter { it % 2 == 0 } .map { (it * it).toString() } .take(5) .collect { count.value = it } // 4 16 36 64 100 } }
- Viewが表示する
activity_main.xml<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{viewModel.count}" />
全体図(超手抜き)
View | ViewModel | UseCase〜深層 ----------------------------------------------- | | イベント → Coroutine起動 → 時間のかかる処理(非同期) | | ↓↓↓ 描画 ←←← 出力データ加工 ←←← 出力データ送信(複数回) | |Flowにはオペレーターがたくさんあり、この左向き時のデータ加工に優れている
Flowの便利さを実感する
ViewModelScopeの補足説明
Dispatcherの補足説明
既存アプリへのFlow導入実例
参考文献
すべての著者さま、ありがとうございます!
- 投稿日:2020-11-16T21:05:48+09:00
UnityPlayerActivity
●Unityプラグインは複数の方法がある(この糞仕様を整理してから株式上場しろよUnityめ)
(1)UnityPlayerActivityファイルを拡張する
UnityPlayerActivity.javaを使ってUnityプラグインを作る。
準備:UnityPlayerActivity.java/classes.jarはUnityExportしたProjectにもある。
Unity向けAndroidネイティブプラグインの作り方
build.gradleを編集して、プラグインにclasses.jarを含めないようにする。含めるとUnity側のclasses.jarと衝突する。
dependencies { // implementation files('libs\\classes.jar') // implementation fileTree(dir: "libs", include: ["*.jar"]) compileOnly fileTree(dir: 'libs', include: ['*.jar'])UnityでAndroid拡張を行うベストプラクティス
final Activity activity = UnityPlayer.currentActivity; で、UnityPlayerActivityを継承しなくてもOK
Unityマルチスレッドレンダリングは外す
低レベルネイティブプラグインインターフェース
Unity のレンダリングは、プラットフォームと CPU の数が一定の条件を満たす場合は、マルチスレッドで行われます。マルチスレッドのレンダリングが使用されるとき、レンダリング API の命令は、MonoBehaviour スクリプトを実行するスレッドとは完全に別の 1 つのスレッド上で行われます。そのため、プラグインがいくつかのレンダリングを即座に開始することが常に可能ではありません。なぜなら、その時レンダースレッドが行っている処理に干渉する場合があるからです。
Unity2018以降(?)はマルチスレッドレンダリングがデフォルト。OpenGLES描画は GL.IssuePluginEvent(GetRenderEventFunc(), 1);経由で行う事。
参考
Androidネイティブ開発のすすめ
https://qiita.com/relzx/items/a35f7ab6dbacb48f7e26UnityでAndroidのネイティブプラグインを使用する3つの方法
https://qiita.com/tempura/items/5e7992896c0faba08da3
- 投稿日:2020-11-16T20:43:23+09:00
Android 11以降でWebViewでローカルHTMLが表示できないとき
※本記事は、NativeScript-Vueのコードを提示していますが、考え方は、どの開発言語・フレームワークでも同一と思われます。予めご了承ください。
陥った事象
最近、NativeScript-Vueというものを使って、Androidネイティブアプリケーションを試しに作ってみたりしています。Vue.jsに慣れている人であれば、楽にネイティブアプリを作ることができるということで、使わせてもらっています。
さて、開発をしている最中に、UIコンポーネントの1つである「WebView」を使って、アプリ内のアセットとして配置しているHTMLファイルを表示させたい場面がありました。
通常であれば、NativeScript-Vueの場合、次のように書きます。
./src/components/App.vue<template> <Frame> <Page> <GridLayout columns="*" rows="*"> <!-- ↓ここでWebViewコンポーネントを配置して、srcとしてassetsディレクトリ内のファイルを指定 --> <WebView row="0" col="0" src="~/assets/index.html" /> </GridLayout> </Page> </Frame> </template>しかしこの状態で、アプリをAndroid 11上で動作させると、次のような「ERR_ACCESS_DENIED」という表示になってしまいました。エミュレータでも実機でも同様です。
そして、試しにAndroid 10のエミュレータで動作確認すると、正常に表示されるのです。
解決方法
Androidのリファレンスに次のようなことが書かれていました。
setAllowFileAccess
The default value is true for apps targeting Build.VERSION_CODES.Q and below, and false when targeting Build.VERSION_CODES.R and above.
バージョンコードR(つまりAndroid 11)から、WebSettingsのAllowFileAccessの設定が、デフォルトで
false
になっていますよ、とのことです。ですので、明示的にtrue
にしてあげることで、解決できそうです。NativeScript-Vueの場合、次のように、Loadedイベントと組み合わせることで、解決できました。
./src/components/App.vue<template> <Frame> <Page> <GridLayout columns="*" rows="*"> <!-- ↓ここでWebViewコンポーネントを配置して、loadedイベントを捕捉 --> <WebView row="0" col="0" @loaded="webViewLoaded" /> </GridLayout> </Page> </Frame> </template> <script lang="ts"> export default { methods: { // loadedイベントが発生した時の処理 webViewLoaded(args) { if (args.object.android) { args.object.android.getSettings().setAllowFileAccess(true); // ここで、setAllowFileAccessメソッドを使って、trueにする args.object.src = "~/assets/map/index.html"; // 上記設定をしてから、ファイルを初めて読み込む } }, }, }; </script>ただ、同リファレンスにも少し書かれているのですが、このような方法でHTMLを表示させるよりも、より安全な方法があるのかもしれません。
※結局自己解決したのですが、もともとStackOverflowで質問していました。
- 投稿日:2020-11-16T13:02:39+09:00
【スタージュンも認めた】グルメスパイザー欲しいけど1103兆3543億円もするのでandroidアプリ作った
お断り
主はandroidアプリ開発、java言語が今回が初めてなのでレイアウトがボロボロだったり所々突っ込みどころがあると思います。
ネタが嫌いな方はブラウザバック推奨します
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇
⌇グルメスパイザーとは
バンダイから発売されてしまった赤い腕型のおもちゃ。正式名称は「トリコ 菓子粉砕機グルメスパイザー」。中にスナック菓子を入れてレバーをシャカシャカ動かすと、内部で菓子が粉砕され、ふりかけとなって出てくる仕組み。ぶっちゃけタカラトミーのおかしなフリカケのパクr。
CMも作られており、トリコが嬉しそうにグルメスパイザーをシャカシャカしている。以下から引用
対象読者
・1103兆3543億円もってない人
・手軽に菓子粉砕したい人
・美食家
・スタージュン
・どこでもポン!クラッシュ!クラッシュ!パッパッパ!したい人動画:説明あり
https://www.nicovideo.jp/watch/sm37827489
https://www.youtube.com/watch?v=CkE88pO2VAIダウンロード、インストール
野良アプリになるのでgumbyさんの記事参考にしてもらえればインストールできると思います
開発期間とか
企画、要件定義とか => 3秒
環境構築、プログラミング =>6時間くらい
合計6時間3秒です機能
PON!! CRASH!! UMASOO!! GOURMETSPYZER!!
ボタンを押下するとトリコが喋ります
スパイスをかける音
ボタンを押下するとフラッシュライトが光り例のシーンが簡単に再現できます
ボタンを再度押下するとフラッシュライトが消えます動作確認
huwei p30 lite
ソース
MainActivity.javaimport android.media.AudioAttributes; import android.media.SoundPool; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.Button; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraManager; import android.os.Handler; import android.content.Context; public class MainActivity extends AppCompatActivity { private SoundPool soundPool; private int soundPon, soundCresh,soundUmasooo,soundGourmetSpyzer,UfoSoundEffect; private Button button1, button2,button3,button4,button5; private CameraManager McameraManager; private String McameraID; private boolean SW; @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); McameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); AudioAttributes audioAttributes = new AudioAttributes.Builder() // USAGE_MEDIA // USAGE_GAME .setUsage(AudioAttributes.USAGE_GAME) // CONTENT_TYPE_MUSIC // CONTENT_TYPE_SPEECH, etc. .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build(); soundPool = new SoundPool.Builder() .setAudioAttributes(audioAttributes) // ストリーム数に応じて .setMaxStreams(5) .build(); // wav をロード soundPon = soundPool.load(this, R.raw.pon, 1); soundCresh = soundPool.load(this, R.raw.crash, 1); soundUmasooo = soundPool.load(this, R.raw.umasooo, 1); soundGourmetSpyzer = soundPool.load(this, R.raw.gourmetspyzer, 1); UfoSoundEffect = soundPool.load(this, R.raw.ufosoundeffect, 1); soundPool.setOnLoadCompleteListener(new SoundPool.OnLoadCompleteListener() { @Override public void onLoadComplete(SoundPool soundPool, int sampleId, int status) { Log.d("debug", "sampleId=" + sampleId); Log.d("debug", "status=" + status); } }); button1 = findViewById(R.id.button1); button2 = findViewById(R.id.button2); button3 = findViewById(R.id.button3); button4 = findViewById(R.id.button4); button5 = findViewById(R.id.button5); button1.setOnClickListener(v -> { // pon.wav の再生 // play(ロードしたID, 左音量, 右音量, 優先度, ループ,再生速度) soundPool.play(soundPon, 1.0f, 1.0f, 0, 0, 1); }); button2.setOnClickListener(v -> { // crash.wav の再生 soundPool.play(soundCresh, 1.0f, 1.0f, 1, 0, 1); }); button3.setOnClickListener(v -> { // Umasooo.wav の再生 soundPool.play(soundUmasooo, 1.0f, 1.0f, 1, 0, 1); }); button4.setOnClickListener(v -> { // gourmetspyzer.wav の再生 soundPool.play(soundGourmetSpyzer, 1.0f, 1.0f, 1, 0, 1); }); button5.setOnClickListener(v -> { soundPool.play(UfoSoundEffect, 1.0f, 1.0f, 1, 0, 1); //カメラを取得できなかった時は何もしない if (McameraID == null) { return; } try { if (SW == false) { //SWがfalseならばトーチモードをtrueにしてLDEオン if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { McameraManager.setTorchMode(McameraID, true); } } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { McameraManager.setTorchMode(McameraID, false); } //SWがtrueならばトーチモードをfalseにしてLDEオフ } } catch (CameraAccessException e) { //エラー処理 e.printStackTrace(); } }); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { McameraManager.registerTorchCallback(new CameraManager.TorchCallback() { //トーチモードが変更された時の処理 @Override public void onTorchModeChanged(String cameraId, boolean enabled) { super.onTorchModeChanged(cameraId, enabled); //カメラIDをセット McameraID = cameraId; //SWに現在の状態をセット SW = enabled; } }, new Handler()); } } }activity_main.xml<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <Button android:id="@+id/button1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="20dp" android:layout_marginRight="20dp" android:layout_marginBottom="20dp" android:text="pon!!" app:backgroundTint="#F44336" app:layout_constraintBottom_toBottomOf="@+id/imageView" app:layout_constraintEnd_toEndOf="@+id/button5" /> <Button android:id="@+id/button2" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginLeft="16dp" android:layout_marginBottom="20dp" android:text="crash!!" app:backgroundTint="#F44336" app:layout_constraintBottom_toBottomOf="@+id/imageView" app:layout_constraintStart_toEndOf="@+id/button3" /> <Button android:id="@+id/button3" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="20dp" android:layout_marginLeft="20dp" android:layout_marginBottom="20dp" android:text="umasoo!!" app:backgroundTint="#F44336" app:layout_constraintBottom_toBottomOf="@+id/imageView" app:layout_constraintStart_toStartOf="parent" app:strokeColor="#F44336" /> <Button android:id="@+id/button4" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginLeft="16dp" android:layout_marginEnd="9dp" android:layout_marginRight="9dp" android:layout_marginBottom="32dp" android:text="GourmetSpyzer!!" app:backgroundTint="#F44336" app:layout_constraintBottom_toTopOf="@+id/button2" app:layout_constraintEnd_toStartOf="@+id/button5" app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintStart_toStartOf="parent" /> <ImageView android:id="@+id/imageView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="4dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/home" /> <Button android:id="@+id/button5" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="24dp" android:layout_marginRight="24dp" android:text="スパイスをかける音" app:backgroundTint="#F44336" app:layout_constraintBaseline_toBaselineOf="@+id/button4" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/button4" /> </androidx.constraintlayout.widget.ConstraintLayout>後書き
週末を返せ
- 投稿日:2020-11-16T02:20:23+09:00
Unity Device Simulator のインストール方法
背景
Device Simulatorのインストール方法が変わったようなので、忘却録として。。。
開発環境
PC:macOS Catalina
Unity:2020.1.10f1内容
「Unity Device Simulator インストール」で検索すると、
同じような検索結果が出てくる。
メニューのwindowからPackage ManagerをクリックするとPackage Managerのダイアログが表示される。
ダイアログのAdvancedからshow preview pakagesにチェックを入れて、Device Simulatorで検索すれば見つかる、、、が肝心のAdvancedがない。調べてみると、Unity 2020.1以降からインストール方法が変わったらしい。(参考のUnity 日本語ヘルプデスクに書いてあります)
書いてある通りにやれば、無事インストールでき、GameタブにGameとSimulatorを選択できるようになる。
Safe Areaを表示したり、回転することもできる。パッケージのインストール
参考
- 投稿日:2020-11-16T02:09:52+09:00
Google Playに公開していたアプリが削除された話と復旧させた話
概要
先日、Google Playに公開していたアプリ2件が突然Googleに削除されてしまったのですが、申請して復活してもらったので、その内容について書きます。
対象アプリ
今回、自分がGoogle Playに公開している2件のアプリが同時に削除されました。そのアプリは次の2件になります。
本アプリは通常のアプリではなく、Medolyという音楽再生アプリから送信されるイベントに反応して、各種アクションを実行する動作になります。例えば、再生中の音楽情報を送信するといった動作をします。各アプリ単体では、サービスのアカウント情報を登録したり、動作のカスタマイズを行うことができます。
これらをインストールすることで、Medolyから該当サービスの機能が利用できるようになるというアプリ設計になっています。本アプリは2件とも2015年に公開したもので、細々とアップデートしており、つい最近、両アプリ共10/20にアップデートしたばかりでした。
停止の流れ
11/06に、自身のAndroid端末にGoogle Play Consoleアプリから次のような通知が2件表示されました。
アプリやWebサイトを見ると、次のような状態になっていました。
実際ストアのアプリページが開けなくなっていますし、Google Play Consoleを見ると各種情報が閲覧できなくなっています。慌ててメールを確認すると、次のようなメールが2件届いていました。
wa2c デベロッパー各位
審査の結果、Medoly Twitter Plugin(パッケージ名 com.wa2c.android.medoly.plugin.action.tweet)は、ポリシーに違反していると判定されたため、配信が停止され、Google Play から削除されました。
問題: コンテンツの繰り返しに関するポリシーへの違反
Google Play 上の既存のアプリと同じユーザー エクスペリエンスを提供するだけのアプリは認められません。アプリは、独自のコンテンツやサービスを生み出すことによって、ユーザーに価値を提供する必要があります。
どうやら、削除されたのは間違いないようです。削除の要因となった「コンテンツの繰り返しに関するポリシーへの違反」というのは、次のページに書かれています。
Google Play 上の既存のアプリと同じユーザー エクスペリエンスを提供するだけのアプリは認められません。アプリは、独自のコンテンツやサービスを生み出すことによって、ユーザーに価値を提供する必要があります。
違反の例としては、次のようなものが挙げられています。
- 独自のコンテンツや価値を追加せず、他のアプリのコンテンツをコピーしている。
- 機能、コンテンツ、ユーザー エクスペリエンスがほとんど同じである複数のアプリを作成する。このようなアプリのコンテンツがどれも少量である場合は、1 つのアプリにすべてのコンテンツを集約することを検討してください。
要するに、他のアプリのコピーか、機能がほとんど無い低品質なアプリだと判断されたようです。
復旧の流れ
アプリは結構長いこと公開を続けていて、最近のアップデートでも停止に該当する心当たりは特に無かったため、異議申し立ての申請をすることにしました。次のページから申請することができます。
アプリが Google Play から削除された - Play Console ヘルプ
ちなみに、メール内にも申請用のリンクがあり、そこ経由すると必要な情報が自動的に入力された状態から申請できました。
2つ一度に申請するとややこしいですし、片方失敗した時にもう片方を別の内容で申請してみようと考えていたので、一旦Medoly Last.fm Pluginの方だけを対象に申請しました。とりあえず以下のような文面で申請してみました。(日本語でOKです)
本アプリ ( Medoly Last.fm Plugin ) は、外部のLast.fmサービスに対して通知を行うためのアプリケーションです。本開発者の別アプリである Medoly ( https://play.google.com/store/apps/details?id=com.wa2c.android.medoly ) と連係して利用する設計となっており、MedolyからLast.fmへのメッセージ通知や、Last.fmサイトからメタ情報の取得が行えるようになっています。
今回、「コンテンツの繰り返しに関するポリシーへの違反」としてアプリが停止された理由としましては、同様の画面構成で構成された他のアプリや、同開発者の別アプリとなる「Medoly Twitter Plugin」と類似していると判断されているものと推察しています。しかしこれは、設定画面や表示メニューについて標準的な開発手法に則った作りとなっているためであり、コンテンツを繰り返し作成している意図は一切ありません。Medoly Last.fm Pluginは、Medolyに対してLast.fmサービス機能を提供するためのものあり、ユーザに対して他アプリとは異なる独自性の高いユーザエクスペリエンスを提供するアプリとなっています。
上記理由により、アプリの再有効化をご検討いただけないでしょうか。
上記申請を出したのが11/6で、その翌日11/7に以下の返答がありました。
Google Play チームにお問い合わせいただきまして、誠にありがとうございます。
アプリのステータス:
Medoly Last.fm Plugin (com.wa2c.android.medoly.plugin.action.lastfm)
Medoly Twitter Plugin (com.wa2c.android.medoly.plugin.action.tweet)
Google Play での公開が停止されており、お客様の対応が必要です詳しく検討させていただいた上で、お客様の異議申し立てが承認されました。Google Play にアプリを再度公開するために必要な追加の手順について、以下に詳しくご説明いたします。
何と1件の申請に対してアプリが2件とも復旧されました。
その後に操作手順が続きますが長いので省略。作業内容を端的に書くと「ストアの公開情報を更新して保存すること」です。メール内容に以下のような文面があるので、更新さえできれば何でも良いようです。送信ボタンがグレー表示の場合は、ストアの掲載情報に小さな変更を加えるとボタンがアクティブになります。たとえば、アプリのタイトルの後ろにスペースを入れてから削除してみてください。ボタンが青色になったら、アップデートを送信していただけます。
Google Play Consoleのページを見ると、ストア情報の閲覧ができるようになり、表示データも一部復旧しています。指示通りストア情報を更新して保存し、2日ほど経った11/9に更新の通知が来て、無事アプリがストアに復旧しました。
停止原因の推察
今回、「コンテンツの繰り返しに関するポリシーへの違反」としてアプリが削除されましたが、その原因について推察してみました。
先に説明したように、本アプリは別アプリからのイベントによって駆動する事を想定したもので、アプリ単体ではアカウントの登録や動作設定を行うだけで、アプリの機能を利用することができません。そのため、表面的な機能は貧弱なものです。
加えて、自分のアプリは起動すると以下のような画面になっています。正直、画面をパッと見てもポリシーへの違反と言われるのも我ながら納得してしまう簡素な画面です。Googleのチェック担当者がこれをポリシー違反と判断するのも止むなしかな…という気持ちはあります。
ちなみに、10/20で行ったアップデートは、画面の内部的な構成を少し変更しただけで、表面的な部分はほとんど変わっていないです。
なぜこのタイミングか
しかし、アプリ自体は5年ぐらい前から公開されているもので、少しずつアップデートを行ってきましたが、この画面も昔からほぼ変化していません。それが、何故今になって削除されたのでしょうか。
まず、2本とも削除されたところを見る限り、10/20にアップデートを行ったことが引き金となっているのは恐らく間違いないかと思います。ストアにアプリが一旦公開された後、人間の手によるアプリチェックが行われて、そこでポリシー違反と判断されたものと推測しています。つまりAndroidアプリは、一度ストアに公開された後も、削除される可能性があるということになります。
ここでどのようなチェックが行われているか分かりませんが、機械的なチェックではなく人間による判断が行われていると考えられるので、判断する人によってブレが生じているのではないかと個人的には考えています。考えられる対策
今回の「コンテンツの繰り返しに関するポリシーへの違反」を引き起こさないためにはどうしたら良いかは、正直よく分からないです。ただ、以下のような点に気を付けた方がいいのかなと思います。
- 見た目に独自性を持たせる。簡素なメニューやボタンだけの構成は避ける。
- アプリ単体で動作する機能を持たせる
- ストアの説明をきちんと書く。単体で動かないなら、その旨をきちんと説明する。
要するに、人が実際に動かした時にきちんとしたアプリとして動作しているように見せるということでしょうか。
申請時に気を付けること
今回自分は、異議申し立て申請時の文面に以下のような点について気を付けて書くようにしました
- アプリがどういう機能を有しているか
- アプリがポリシー違反に該当しないと考えている理由
- 分かりやすく書く
- 感情的に書かない
一連の流れを見る限り、削除の判断は人間が行っているものと考えられます。そのため、その担当者がアプリを理解し、アプリがポリシー違反でないと判断してもらう必要があります。あくまでも人に伝えることが前提なので、不満や文句など感情的な文章を書くのは避けて、人間が機能や申請理由を理解しやすい形で伝えるように努めた方が良いと思います。なので、コピペのような機械的な文章は避けた方が良さそうです。
結果としてアプリが復旧したので、この判断はそれほど間違っていないと思います。まとめ
今回アプリが削除された事例から、Google Playの公開アプリについて以下のような学びがありました。
- Google Playにアプリが公開された後でも、ポリシー違反で突然予告なく削除されることがある。
- 長く公開されているアプリでも、アップデート時のチェックで容赦なく削除される。
- アプリのポリシー違反などのチェックは人の手で行われているらしい。
- アプリの見た目を適当に作ると、チェック担当者によってポリシー違反と判断される場合がある。
- アプリを削除停止しても、復旧申請(異議申し立て)で復活できる場合がある。
余談
アプリが削除されたのは今回で2回目で、5年ほど前に別のアプリが1度停止されたことがあります。理由はアイコンがポリシー違反と判断されたもので、こちらは復旧不可能でした(未だにGoogle Play Consoleに残っています)。ポリシー違反の種類や内容によっては、アプリが復旧不可能な場合もあるので、復旧できることをアテにしない方が良いかと思います。