- 投稿日:2020-01-12T22:56:44+09:00
[Unity] ビルド関連処理を自分なりに基盤化し始めました
目的
- アプリビルドのCIなどで要求される機能を実装
現在の機能
- 設定ファイルおよびSchemeに応じたビルド設定の反映
- ライブラリの追加を避けつつデシリアライズ出来るようXMLに記述
- が、今後やりたい事を踏まえるとスクリプトでも扱いやすい形式にすべきか悩み中
- KeystoreやAppleDeveloperTeamIdなども設定ファイルから切り替え
- ログを詳細と簡易で出し分け
- AndroidでExport Projectしつつapk出力まで対応
実行
- macOSでもWindowsでもbashから以下のコマンドで実行可
sh Batch/AppBuild.sh -p [プラットホーム] -c [設定名] -s [scheme] -e [実行メソッド名]成果物
追加でやりたい事
- アプリアイコンの差し替え
- バージョン変更(BuildNumberのインクリメント)
- テスト公開用の各ストアへのアップロード
- Deploygateへのアップロード
- Build And Run対応
今後
- 自作アプリ開発と合わせて機能追加や改修が入り次第フィードバックしていきます
- Scriptable Build Pipeline を使えばもっと簡易化できるかもです
- 投稿日:2020-01-12T19:27:38+09:00
DataBindingでsealed classを使いたい
DataBindingでsealed classを使う
用意したsealed class
sealed class SealedClass(val str: String) { object Hoge : SealedClass("hoge") object Fuga : SealedClass("fuga") }
通常通りDataBindingを使う場合<data> <import type="SealedClass"/> </data> <androidx.appcompat.widget.AppCompatButton android:id="@+id/wish" android:layout_width="match_parent" android:layout_height="wrap_content" android:onClick="@{view -> viewModel.setHoge(SealedClass.Hoge)}" android:text="@{SealedClass.Hoge.str}"/>
これだとエラーが発生してしまうので、以下の書き方に変更する。<data> <import type="SealedClass"/> </data> <androidx.appcompat.widget.AppCompatButton android:id="@+id/wish" android:layout_width="match_parent" android:layout_height="wrap_content" android:onClick="@{view -> viewModel.setHoge(SealedClass.Hoge.INSTANCE)}" android:text="@{SealedClass.Hoge.INSTANCE.str}"/>xmlから
object
を呼ぶ場合はINSTANCE
をつける。
- 投稿日:2020-01-12T15:21:00+09:00
文字列→塩基配列の相互変換ツールをつくってみた(アプリ版)
はじめに
この記事で公開したアプリの中身についてです。
この記事(どこまでショボいアプリがAppleの審査に通るのか試してみた)をみてわりと機能が少なくてもアプリ公開できるのか!と思い正月休みにアプリをつくってみました。
以前作ったこれ(文字列→塩基配列の相互変換ツールをつくってみた(PHP))をアプリにしてリリースしました。
リリースしたアプリ
つくったアプリは有料です。(目指せ!!トータルダウンロード数25!!!)
- Mac, iOS: ¥120
- Android : ¥100
Macアプリ
ターゲット:MacOS Catalina以降
iOSアプリ
ターゲット:iOS13以降
Androidアプリ
ターゲット:Android6.0以降
Web版
こんなアプリに金払いたくねぇよって人はぜひWeb版をどうぞ
http://adventam10.php.xdomain.jp/dna/index.php
アプリ概要
機能は極小で文字列⇔塩基配列を相互変換し、Twitterに投稿できるアプリです。(一応英語版もつくりました)
文字列->塩基配列 塩基配列->文字列 Macアプリ
Macアプリは一発で審査が通ったのでiOSと比べると機能が少ないです。
機能
- 文字列⇔塩基配列を相互変換
- Twitterに投稿機能
- 塩基配列の他アプリへの共有機能
- 塩基配列のテキストファイルへの書き出し機能
- 塩基配列のペーストボードへコピー機能
iOSアプリ
iOSアプリは4回リジェクトされたので他と比べると機能が多いです。
機能
- 文字列⇔塩基配列を相互変換
- Twitterに投稿機能
- 塩基配列の他アプリへの共有機能
- 塩基配列のテキストファイルへの書き出し機能
- 塩基配列のペーストボードへコピー機能
- 塩基配列の履歴機能(10件まで)
- 音声入力機能
1回目の審査では共有機能が使えないけど?バグじゃね?って理由でリジェクトされたのですが、2回目の審査で下記が追加されました
Guideline 4.2 - Design - Minimum Functionality履歴機能追加 -> リジェクト、音声入力機能追加 -> 通過
音声入力機能追加後にアプリをアップしようとすると下記のようなメールが来ました
ITMS-90683: Missing Purpose String in Info.plist - Your app's code references one or more APIs that access sensitive user data. The app's Info.plist file should contain a NSSpeechRecognitionUsageDescription key with a user-facing purpose string explaining clearly and completely why your app needs the data. Starting Spring 2019, all apps submitted to the App Store that access user data are required to include a purpose string. If you're using external libraries or SDKs, they may reference APIs that require a purpose string. While your app might not use these APIs, a purpose string is still required. You can contact the developer of the library or SDK and request they release a version of their code that doesn't contain the APIs. Learn more (https://developer.apple.com/documentation/uikit/core_app/protecting_the_user_s_privacy).ローカライズ対応していたので Info.plist にキーは追加せずに InfoPlist.strings に下記のように記述していたのですがそれではダメなようです。
"NSSpeechRecognitionUsageDescription" = "音声入力するために必要です"; "NSMicrophoneUsageDescription" = "音声入力するために必要です";Info.plist にもキー追加して同じように記述してやると通りました。Info.plist にも記載するとこちらの記載が優先されて InfoPlist.string の文字が表示されないと思ったのですがそうでもないようです。(ちゃんとローカライズされてました。)
Androidアプリ
Androidアプリはあんまさわったことがなかったので、最小構成です。(がんばってiOSアプリを追従するようにします!)
機能
- 文字列⇔塩基配列を相互変換
- Twitterに投稿機能
Web版
Webもほぼさわったことないので、最小構成です。(一応レスポンシブ対応はしてます。)
機能
- 文字列⇔塩基配列を相互変換
- Twitterに投稿機能
アプリのコードについて
このアプリのきもは文字列⇔塩基配列なのですがそこのコードについてです。
ソースは全部 GitHub で公開してます。方法
変換方法は間に16進数をかませてやってます。
最初は4進数に変換してそれぞれ [0, 1, 2, 3] -> [A, T, C, G] のように変換していたのですが、以前コメントで 0⇔AA みたいに2文字ずつやれば16進数でいけるよと教えていただきました
文字列->塩基配列
- 文字列 -> 2進数に変換
- 2進数 -> 16進数に変換
- 16進数 -> 塩基配列に変換(ATCG)
塩基配列->文字列
- 塩基配列 -> 2文字ずつに分割
- 分割文字列 -> 16進数に変換
- 16進数 -> 2進数に変換
- 2進数 -> 文字列に変換
変換コード
もっといい方法があればぜひ教えて下さい!!
swift
swift では String の Extension で文字列から16進数への変換、16進数から2進数への変換、文字列を2文字ずつ分割する変数とメソッドをつくりました。(swift が一番めんどくさい感じになってしまいました...
)
StringExtensions.swiftpublic extension String { // 16進数->2進数への変換 var hexadecimal: Data? { var data = Data(capacity: count / 2) let regex = try! NSRegularExpression(pattern: "[0-9a-f]{1,2}", options: .caseInsensitive) regex.enumerateMatches(in: self, range: NSRange(startIndex..., in: self)) { match, _, _ in let byteString = (self as NSString).substring(with: match!.range) let num = UInt8(byteString, radix: 16)! data.append(num) } guard data.count > 0 else { return nil } return data } // 文字列->16進数の変換 var hex: String { let data = self.data(using: .utf8)! return data.map { String(format: "%02X", $0)}.joined() } // 指定文字数で文字を分割する func splitInto(_ length: Int) -> [String] { var str = self for i in 0 ..< (str.count - 1) / max(length, 1) { str.insert(",", at: str.index(str.startIndex, offsetBy: (i + 1) * max(length, 1) + i)) } return str.components(separatedBy: ",") } }private let dnaHexValues: [String: String] = ["AA": "0", "AT": "1", "AC": "2", "AG": "3", "TA": "4", "TT": "5", "TC": "6", "TG": "7", "CA": "8", "CT": "9", "CC": "a", "CG": "b", "GA": "c", "GT": "d", "GC": "e", "GG": "f"] // 文字列->塩基配列 func convertToDNA(_ text: String?) -> Result<String, DNAConvertError> { if isEmptyText(text) { return .failure(.empty) } var result = text!.hex.lowercased() dnaHexValues.forEach { dna, hex in result = result.replacingOccurrences(of: hex, with: dna) } return .success(result) } // 塩基配列->文字列 func convertToLanguage(_ text: String?) -> Result<String, DNAConvertError> { if isEmptyText(text) { return .failure(.empty) } if isInvalidDNA(text) { return .failure(.invalid) } let hex = text!.splitInto(2).compactMap { dnaHexValues[$0] }.joined() if hex.isEmpty { return .failure(.invalid) } if let data = hex.hexadecimal, let result = String(data: data, encoding: .utf8) { return .success(result) } return .failure(.invalid) }kotlin
kotlinが一番スッキリした感じにかけました。
val dnaHexValues = mapOf( "AA" to "0", "AT" to "1", "AC" to "2", "AG" to "3", "TA" to "4", "TT" to "5", "TC" to "6", "TG" to "7", "CA" to "8", "CT" to "9", "CC" to "a", "CG" to "b", "GA" to "c", "GT" to "d", "GC" to "e", "GG" to "f" ) // 文字列->塩基配列 fun convertToDNA(text: String?): String? { if (text.isNullOrEmpty()) { return null } val hex = text.toByteArray().map { b -> String.format("%02X", b) }.joinToString("") var result = hex.toLowerCase() dnaHexValues.forEach { (k, v) -> result = result.replace(v, k) } return result } // 塩基配列->文字列 fun convertToLanguage(text: String?): String? { if (text.isNullOrEmpty()) { return null } if (isInvalidDNA(text)) { return null } var index = 0 val strings: MutableList<String> = mutableListOf() while (index < text.length) { strings.add(text.substring(index, index+2)) index += 2 } val hex = strings.map { n -> dnaHexValues[n] }.joinToString("").toUpperCase() val result = ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() } return String(result) }PHP
PHPは変換のときにバックスラッシュいれないといけなくてなんか冗長な感じになりました。
// 文字列->塩基配列 function convertToDNA($text){ $hex = bin2hex($text); $nucleotideArray = array("AA", "AT", "AC", "AG", "TA", "TT", "TC", "TG", "CA", "CT", "CC", "CG", "GA", "GT", "GC", "GG"); $hexArray = array("/0/", "/1/", "/2/", "/3/", "/4/", "/5/", "/6/", "/7/", "/8/", "/9/", "/a/", "/b/", "/c/", "/d/", "/e/", "/f/"); $result = preg_replace($hexArray, $nucleotideArray, $hex); return $result; } // 塩基配列->文字列 function convertToLanguage($text){ $strArray = str_split($text, 2); $resultArray = array_map("dnaDecode", $strArray); $hex = implode("", $resultArray); return hex2bin($hex); } function dnaDecode($nucleotide){ $nucleotideArray = array("/AA/", "/AT/", "/AC/", "/AG/", "/TA/", "/TT/", "/TC/", "/TG/", "/CA/", "/CT/", "/CC/", "/CG/", "/GA/", "/GT/", "/GC/", "/GG/"); $hexArray = array("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"); $result = preg_replace($nucleotideArray, $hexArray, $nucleotide); return $result; }さいごに
変換後の塩基配列をどうにか圧縮したいのですが、そうすると圧縮した印が必要になったり...(TATAボックスでも付けるか
)
圧縮するにしてもswift, kotlin, PHPで方法は揃える必要があるし...悩みは尽きないです。
- 投稿日:2020-01-12T02:51:53+09:00
Androidでリモートコントローラにリストを表示する際のポイント
概要
ここでは、Android音楽プレイヤーを作る際、メディアの一覧表示に対応したリモートコントローラーにメディア一覧を表示するための情報について書きます。
画像は、Kenwood MDV-Z904W というカーナビの画面で、Play Musicを動かしたときに見られるリストの表示サンプルです。
基本情報
対応機器
ここで述べるメディア一覧を表示できるデバイスですが、その特性上、高い解像度のディスプレイが付いている高機能な物に限定されてきます。具体的には、カーナビ、スマートウォッチなどが該当します(なので、それほど使う人は多くないんじゃないかと思っています)。ここでは、自分の持っているカーナビでの動作を想定しています。
AndroidのMediaSession
Androidで音楽再生アプリ開発を行う場合、MediaSessionを使って音楽情報を管理する方法が提供されています(Android 5以降)。
メディア アプリ アーキテクチャの概要 | Android Developers
MediaSessionはAndroidデバイスにおけるメディア情報の入出力や、メディア再生のコントロールを行います。これを利用することで、ロックスクリーン、通知欄、Bluetoothデバイスなどに対して音楽情報を表示したり、外部からのコントロールを受け付けてメディアの操作を行うことができます。
これの利点は、コントローラの差異を吸収してくれる点です。例えば、再生/停止ボタンの操作を受け付ける場合を考えても、画面操作、通知領域操作、Bluetooth、USB、イヤホンのボタン、といった多数の箇所から発生する可能性が考えられますが、それらを統一したインターフェースでコントロールすることができるようになります。また、画面、通知領域、リモコンなどのディスプレイに表示する楽曲情報をまとめて管理することで、どこに対しても同じ情報を表示させることができるようになります。
もしMediaSessionが無ければそれらに対して個別にコードを書く必要が出てくるため、実に合理的な仕組みです。Androidのメディアプレイヤーアプリは一般的に、この仕組を用いて実装されます。
AVRCP 1.4
BluetoothにはAVRCP(Audio/Video Remote Control Profile)というプロファイルがあり、一般的なBluetoothコントローラは、これを使ってメディアの再生・停止といった操作や、メディア情報の取得を行います(ちなみに、音声の転送はA2DPという別のプロファイルになります)。このAVRCPには、Bluetoothのバージョンとは別にいくつかのバージョンがあり、次のような違いがあります。上位のバージョンは下位のバージョンの機能を含む上位互換性があります。
1.0: 再生・停止、曲送り・曲戻し、早送り・早戻しなどの操作
1.3: 曲名などの表示
1.4: フォルダやトラック情報の表示、選択
1.5: 微修正?
1.6: 画像転送、メディア数カウント(参考: AUDIO/VIDEO REMOTE CONTROL PROFILE)
ここで注目するのは、1.4から追加されたトラック情報の表示機能です。これに対応した機器同士であれば、メディアの一覧を表示することができます。
Androidは8.0からAVRCP1.4に対応しています(参考: Bluetooth Services | Android Open Source Project)。そこで、ここではAndroid 8以降のAndroid端末と、AVRCP1.4に対応した機器を繋いで、トラック一覧を表示させることにします。
ちなみに、Android 8.0以降であれば開発者オプションからAVRCPのバージョンを切り替えることができます。(切り替えると接続が安定しなくなるという報告もチラホラあるようですが。)
実装
MediaBrowserServiceの実装ポイント
まずアプリの設計として、MediaBrowserService(MediaBrowserServiceCompat)を実装して、その上でメディアの再生を行うようにします。ここで実装の詳細は書きませんが、以下のページ等を参照します。
Building a media browser service | Android Developers
メディアアプリのアーキテクチャの概要 | Android デベロッパー | Android Developersここでのポイントをいくつか。
android:exported="true"
MediaBrowserServiceをAndroidManifest.xmlで定義する場合、必ずandroid:exported="true"を付けます。これが無いと、外部からの操作要求を受け付けられません。
AndroidManifest.xmlの記述<service android:name=".module.player.MediaPlayerService" android:exported="true" android:foregroundServiceType="mediaPlayback" > <intent-filter> <action android:name="android.media.browse.MediaBrowserService" /> </intent-filter> </service>onGetRoot / onLoadChildren
MediabrowserServiceを継承すると、onGetRootとonLoadChildrenの2つのメソッドを実装する必要がありますが、これらが外部からリストを要求された時に呼ばれるメソッドです。
MediaBrowser.MediaItem(MediaBrowserCompat.MediaItem)のリストを返すと、呼び出し元にリストが表示されます。onGetRootが初期リスト、onLoadChildrenは子ノードのリストを返します。これらをカスタマイズすることで、呼び出し元に柔軟なリスト表示を行うことができます。余談ですが、戻り値でリストを返すのではなくResultに値をセットすることでリストを返すので、リストの生成に時間がかかる場合は非同期で返すこともできます。
本記事冒頭のサンプル画像を例に上げると、onGetRootに対して「アーティスト」「アルバム」「曲」「ジャンル」「作曲者」というディレクトリのリストを返していることになります。MediaBrowser.MediaItemに余計なデータを含めない
これは自分が少し詰まったポイントです。
リストを返す場合、MediaBrowser.MediaItem(MediaBrowserCompat.MediaItem)のリストを返します。このデータは、初期化時にMediaDescription(MediaDescriptionCompat)を引数に取りますが、これに余計なデータが入っていると結果を返す処理が失敗します。自分の場合、(妙な実装ですが…)MediaDescriptionにMediaMetadataCompatのデータを含んでいたのですが、その場合に以下のようなエラーが発生しました。2019-11-10 14:19:09.878 4737-4769/? V/Avrcp: Enter getFolderItemsRequestFromNative 2019-11-10 14:19:09.878 4737-4769/? V/Avrcp: Exit getFolderItemsRequestFromNative 2019-11-10 14:19:09.880 4737-4864/? E/Parcel: Class not found when unmarshalling: android.support.v4.media.MediaMetadataCompat java.lang.ClassNotFoundException: android.support.v4.media.MediaMetadataCompat at java.lang.Class.classForName(Native Method) at java.lang.Class.forName(Class.java:453) at android.os.Parcel.readParcelableCreator(Parcel.java:2827) at android.os.Parcel.readParcelable(Parcel.java:2781) at android.os.Parcel.readValue(Parcel.java:2684) at android.os.Parcel.readArrayMapInternal(Parcel.java:3051) at android.os.BaseBundle.unparcel(BaseBundle.java:257) at android.os.BaseBundle.getString(BaseBundle.java:1086) at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getAttrValue(BrowsedMediaPlayer.java:712) at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getFolderItemsFilterAttr(BrowsedMediaPlayer.java:671) at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getFolderItemsVFS(BrowsedMediaPlayer.java:527) at com.android.bluetooth.avrcp.Avrcp.handleGetFolderItemBrowseResponse(Avrcp.java:3381) at com.android.bluetooth.avrcp.Avrcp.-wrap34(Unknown Source:0) at com.android.bluetooth.avrcp.Avrcp$AvrcpMessageHandler.handleMessage(Avrcp.java:1114) at android.os.Handler.dispatchMessage(Handler.java:105) at android.os.Looper.loop(Looper.java:164) at android.os.HandlerThread.run(HandlerThread.java:65) Caused by: java.lang.ClassNotFoundException: android.support.v4.media.MediaMetadataCompat at java.lang.Class.classForName(Native Method) at java.lang.BootClassLoader.findClass(ClassLoader.java:1355) at java.lang.BootClassLoader.loadClass(ClassLoader.java:1415) at java.lang.ClassLoader.loadClass(ClassLoader.java:312) at java.lang.Class.classForName(Native Method) at java.lang.Class.forName(Class.java:453) at android.os.Parcel.readParcelableCreator(Parcel.java:2827) at android.os.Parcel.readParcelable(Parcel.java:2781) at android.os.Parcel.readValue(Parcel.java:2684) at android.os.Parcel.readArrayMapInternal(Parcel.java:3051) at android.os.BaseBundle.unparcel(BaseBundle.java:257) at android.os.BaseBundle.getString(BaseBundle.java:1086) at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getAttrValue(BrowsedMediaPlayer.java:712) at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getFolderItemsFilterAttr(BrowsedMediaPlayer.java:671) at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getFolderItemsVFS(BrowsedMediaPlayer.java:527) at com.android.bluetooth.avrcp.Avrcp.handleGetFolderItemBrowseResponse(Avrcp.java:3381) at com.android.bluetooth.avrcp.Avrcp.-wrap34(Unknown Source:0) at com.android.bluetooth.avrcp.Avrcp$AvrcpMessageHandler.handleMessage(Avrcp.java:1114) at android.os.Handler.dispatchMessage(Handler.java:105) at android.os.Looper.loop(Looper.java:164) at android.os.HandlerThread.run(HandlerThread.java:65) Caused by: java.lang.NoClassDefFoundError: Class not found using the boot class loader; no stack trace availableMediaSessionの実装ポイント
MediaSessionによって、外部からのコントロールに対する処理を実装することができます。リストの操作に対する処理もここで定義します。その際のポイントを次に示します。
setQueue
onGetRoot/onLoadChildrenで返したリストとは別に、MediaSessionにはキューのリスト保持することができます(MediaSession.setQueueで設定できます)。これは再生管理に利用したりしますが、Wear OSはこれに基づいて画面にリストを表示しているようです(多分)。
onGetRoot/onLoadChildrenはディレクトリで階層構造が表現できるのに対し、setQueueではメディアのリストしか定義できません。なので、両方に対応する場合は表示方法を工夫する必要があるかと思います。onPlayFromMediaId と onSkipToQueueItem
MediaSession.Callback(MediaSessionCompat.Callback())によって、外部からの操作に対する処理を実装することができます。リスト上でメディアを選択した場合の処理を実装しようと思った場合、onPlayFromMediaIdとonSkipToQueueItemという、よく似た2種類のメソッドがあることに気づきます。
onGetRoot/onLoadChildrenのリストに対して、ユーザが項目の選択操作を行うと、onPlayFromMediaIdが呼ばれます。該当する項目のMedia ID (MediaDescriptionで定義されているID)がパラメータとして渡されるので、それに基づいてメディアの読込や再生といった処理を実装します。Bluetoothデバイスからのリスト選択操作の場合は、こちらが呼ばれます。
一方で、onSkipToQueueItemはMediaSessionが保持しているキューを選択した場合に呼ばれるメソッドになります。Ware OSからのリスト選択操作の場合は、こちらが呼ばれます。パラメータとして渡されるのは、キューを登録する時に設定したIDになります。
両方ともだいたい同じ物なので、両方で同じ実装しておくのが無難だと思います。実行時のポイント
実行時に注意するポイント(自分が詰まったポイント)について書きます。
対応デバイスを使う
繰り返しになりますがリスト対応には、端末と接続先のデバイスの両方でBluetoothのAVRCP1.4対応が必要です(Ware OSの場合は状況が違うかもしれないですが、詳細不明)。なので、事前にスペックをよく確認しておきます。Andoridは8.0からOSレベルで対応しているので、端末側はそれ以降を使っておけば間違いないと思います。
再接続
開発中の話ですが、一度未対応の状態で繋がってしまうと、再接続するまでリストが表示されませんでした。恐らくBluetoothのセッションが確立した段階で対応有無が確立するのだと思います。なので、対応がうまくいかない場合は一旦Bluetoothを切断して接続し直すと改善されるかもしれません。
リスト表示方法の確認
基本的な話として、リストが表示できる場合に画面側にどう表示されるかは事前に確認しておくと良いです。例えば、自分のカーナビであれば、リスト表示可能な場合は再生画面の右下「リスト」という項目が増えて、ここを押すとリストが表示されます。Google Play Musicアプリなどはリストに対応しているので、こちらで事前に表示方法を確認しておくと良いです。
まとめ
以上が、リモートコントローラにリストを表示する際に注意するポイント(というか、自分が詰まったポイント)になります。これを実装したところで、対応可能な状況はとても限られているため、苦労して実装する割に使い所は少ないです。
そのためかネットを検索しても情報があまり出てこなくて、思ったより実装で詰まるポイントが多かったです。せっかくそれだけ苦労したので、ここでざっくりとまとめてみました。どなたかの参考になれば幸いです。その他
余談ですが、MediaSessionには画像を設定する方法が用意されているのですが、カーナビ側にアルバム画像が表示されません。仕様上はBluetoothのAVRCP 1.6以降であれば表示できる気がするのですが、うまくいかないです。iOS側でも駄目なので、恐らくカーナビ側が対応していないためだと思っているのですが、その辺がよく分からず…。
関連リンク
メディア アプリ アーキテクチャの概要 | Android Developers
ゼロから学ぶメディアプレイヤーの実装 | Developers.IO
- 投稿日:2020-01-12T01:55:25+09:00
iOS, Mac, Androidのアプリをリリースしてみた
はじめに
アプリのリリース方法の備忘録です。
今からアプリをリリースしたい人はとりあえずデベロッパー登録してください
登録まで2日くらいかかります。休日はさむともっとかかるのでアプリ作る前にデベロッパー登録しておく方がスムーズに進みます。
- Apple: 年会費 ¥12,980(2020/1時点)
- Google: 登録費 $25.00(2020/1時点)
全部 Macbook Pro macOS Catalina(10.15.2)で作業しました。
リリースしたアプリ
Macアプリ
ターゲット:MacOS Catalina以降
iOSアプリ
ターゲット:iOS13以降
Androidアプリ
ターゲット:Android6.0以降
Macアプリのリリース
開発環境
Xcode 11.3
リリース時に必要なもの
- アプリアイコン(iOSと共通可)
- プライバシーポリシーのサイト(iOSと共通可)
- ストア用のスクショ2枚(iOSと別々)
アプリアイコン作成
GIMP を使って1024 x 1024 ピクセルのアイコンを作成しました。
Googleで「iOSアイコン グリッド」検索して出てきたグリッドガイドを参考に適当にこのへんの画像を配置してつくりました。アイコンのリサイズ
MakeAppIcon を使ってリサイズしました。
アイコンをアップしたくないって人はGIMP用のスクリプトつくったのでどうぞ
導入方法はこちらを参考にしてください。GIMPで1024x1024の画像を開いてフィルター(R)->Python-Fu->iOS-resizeIconsで実行できます。実行するとapp_iconsフォルダにリサイズ後のアイコンがあるはずです。
デベロッパー登録
ここ Apple Developer Program - Apple Developer で登録します。
下記参考に登録しました。
【2016年最新版】わかりやすく徹底解説!Apple Developer Programへの登録手順(個人)スクショ作成
審査提出時にアスペクト比16:10の下記のいずれかのサイズのスクショが最低2枚必要です。(ローカライズ対応する場合は同じ画像でも申請できますが個別に用意した方がいいです)
- 1280 x 800 ピクセル
- 1440 x 900 ピクセル
- 2560 x 1600 ピクセル
- 2880 x 1800 ピクセル
参考:App Store Connect ヘルプースクリーンショットの仕様
わたしは Mac でアプリをフルスクリーン状態にしたらアス比が 16:10 だったので、フルスクリーン状態のスクショをとって 2880 x 1800 ピクセルにリサイズしました。この方法は文字とかめっちゃ小さくなるのであまりおすすめできません...(どなたかいい方法教えて下さい
)
プライバシーポリシーのサイト作成
アプリをリリースするには、データ収集する機能があるかないかに関わらず、プライバシーポリシーが必要になりました。
下記参考に作成しました。めんどくさかったのでGitHub Pagesで対応しました。
プライバシーポリシーの中身は下記で作成しました。
App Privacy Policy Generatorローカライズ対応する場合も英語版のみで審査は通過しました。
提出時にサポートURLも入れないといけないようですがとりあえずプライバシーポリシーと同じURLを入れると通りました...(なんか作成しないといけないんだろうか?
)
アプリをApp Store Connectで審査提出
証明書つくったり、アーカイブしたりしてするのは下記参考にしました。
審査
ここまで準備すると審査に出せるはずです。しばらくすると下記のようなメールが届きます。
The status of your (macOS) app, アプリ名, is now "Waiting For Review"審査は驚くほど早く3時間ほどで再びメールが来ました!!下記のようなメールが来ると審査通過です。
The status of your (macOS) app, アプリ名, is now "In Review"残念ながら下記のようなメールが来るとリジェクトです。。。
New Message from App Store Review Regarding アプリ名続いて下記の様なメールが来た場合は審査は通ったけどストアにあげるのは保留中です。
The status of your (macOS) app, アプリ名, is now "Pending Contract"わたしの場合は有料アプリでローカライズ対応していたので App Store Connect の「契約/税金/口座情報」で各種情報を入力する必要があります。
ステータスは他にも色々あるようです
App Store Connect ヘルプーApp ステータス契約/税金/口座情報の入力
おそらく無料アプリの場合は必要ないかと思います。
このあたりはあまりよくわからないままやったのであってるか不明です...(自己責任でお願いします)
- 銀行口座情報入力
- 納税フォーム入力(下記参考に入力しました)
日本在住のエンジニアが米国App Storeでアプリを公開する時にTax Informationに設定した内容と結果- 連絡先入力
5つすべて同じ連絡先を入力しましたここまでやるとしばらくして下記のメールが届き無事リリースされました
Welcome to the App StoreiOSアプリのリリース
下記は Mac アプリと共通です。
- アプリアイコン作成
- デベロッパー登録
- プライバシーポリシーのサイト作成
- 審査
- 契約/税金/口座情報の入力
開発環境
Xcode 11.3
リリース時に必要なもの
- アプリアイコン(Macと共通可)
- プライバシーポリシーのサイト(Macと共通可)
- ストア用のスクショ3枚(Macと別々)
スクショ作成
iPhone, iPad 対応だったので下記スクショが必要でした。
- iPhone 6.5インチディスプレイ
- iPhone 5.5インチディスプレイ
- iPad Pro (第3世代) 12.9インチディスプレイ
- iPad Pro (第2世代) 12.9インチディスプレイ
それぞれ1枚ずつでいけました。(iPad は同じ画像でいけました)
ローカライズ対応する場合は同じ画像でも申請できますが個別に用意した方がいいです。Appetize.io にアップすると色々な端末のスクショが取れて便利らしいです。
参考:iOSアプリをストアにあげる時に必要なものメモこれであとはMacアプリと同じ手順で申請できます
Androidアプリリリース
開発環境
Android Studio 3.5.3
リリース時に必要なもの
- アプリアイコン
- ストア用のスクショ2枚
- ストアのフィーチャー グラフィック用画像
アプリアイコン作成
GIMP を使って 512 x 512 ピクセルのアイコンを作成しました。
デベロッパー登録
下記参考に登録しました。
Google Play Developerに登録するスクショ作成
スマホとタブレットで別れており、全体で最低2枚のスクショが必要らしいです。
わたしはスマホ用2枚のみアップしました。フィーチャー グラフィック用画像
横 1,024 x 縦 500の JPG または 24 ビット PNG(アルファなし)画像が必要なようです。
参考:Google Playガイドー注目を集めるフィーチャー グラフィックでアプリをアピールする
アプリをGoogle Play Consoleで審査提出
アプリの提出は下記を参考にしました。
Google Play Storeにアプリを公開する(わたしはレーティング設定のアンケートを1つ回答し忘れていてずっと処理中で止まってました
)
提出後、5日くらいで下記のメールが届き無事リリースされました
IARC Live Rating Notice: アプリ名公開後はすぐにはストアの検索にはひっかからないようで24時間位かかるそうです。それまではURL直打ちとかじゃないとたどり着けませんでした
おまけ(Web)
Web版も公開しました。
http://adventam10.php.xdomain.jp/dna/index.php注意:httpです。スマホでみると広告出ます(PCは出ないはず)
方法
ファビコン作成
特に必須ではありませんがせっかくなんでファビコンを作成しました。
下記に Android のアイコン(512 x 512)をアップしてつくりました。
様々なファビコンを一括生成。favicon generator公開
下記参考にXFREEで公開しました。
無料でできるPHPのWEBサイト公開(テスト用)さいごに
今まではソースごと納品とかストア公開は別の人が担当とかでストア公開したことありませんでしたが、これでようやくわたしもデベロッパーを名乗れます!!
つくったアプリは有料です![]()
- Mac, iOS: ¥120
- Android : ¥100
目標は Google Play の登録料の $25.00 のトータルダウンロード数25!!!
こんなアプリに金払いたくないって人はぜひWeb版をどうぞ
- 投稿日:2020-01-12T00:28:04+09:00
adbコマンド
Androidアプリを開発するにあたって、
よく使うADBコマンドからたまにしか使わないけど毎回調べてしまうものまとめました。アプリ(APK)のインストール
デバイスに未インストール
adb installインストール済み(上書きインストール)
adb install -rインストール済み(ダウングレード)
adb install -d接続済み端末の確認
adb devices端末指定
adb -s <device id> *device idは接続済み端末の確認のコマンドにて取得
*はinstall, uninstallなど指定した端末に対して行いたいコマンドファイルの転送
adb push <転送するファイル名> <転送先のパス>ファイルの取得
adb pull <取得元のパス>AlarmManagerの設定時刻
【Android】adbを使ってAlarmManagerの実行予定時間を確認する
adb shell dumpsys alarm | find "<package name>"ログ取得
adb logcatログクリア
adb logcat -cログファイルを取得
adb logcat > hoge.txtADBのバージョン
adb versionアプリアンインストール
adb uninstall文字入力
adb shell input text <入力したい文字列>インストール済みパッケージリスト
adb shell pm list packages参考文献
- 投稿日:2020-01-12T00:03:20+09:00
FlutterのListViewでリストを削除した際のA dismissed Dismissible widget is still part of the tree
はじめに
最近Flutterの勉強を開始しました。
初めてのアプリということで、ToDoアプリの開発を行っていました。
ListViewというwidgetを使っているのですが、途中エラーが発生し困ったので解消法の共有です。
超初心者向けなのであしからず。
現象
上記のようなTodoリストを作成し、各 TodoはFlipすることで削除できるようにしました。
しかし、test3→test2→test1の順で削除すると、問題なく削除されるのですが、test3を削除する前にtest2を削除するとエラーが発生するという事象に会いました。エラー内容
Performing hot reload... Reloaded 1 of 478 libraries in 396ms. flutter: ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════ flutter: The following assertion was thrown building Dismissible-[<'0'>](dirty, dependencies: flutter: [Directionality], state: _DismissibleState#74b1e(tickers: tracking 2 tickers)): flutter: A dismissed Dismissible widget is still part of the tree. flutter: Make sure to implement the onDismissed handler and to immediately remove the Dismissible widget from flutter: the application once that handler has fired. flutter: flutter: The relevant error-causing widget was: flutter: Dismissible-[<'0'>] flutter: file:///Users/kazuma/Desktop/myproject/flutter-study/todo_app/lib/main.dart:53:20 flutter: flutter: When the exception was thrown, this was the stack: flutter: #0 _DismissibleState.build.<anonymous closure> (package:flutter/src/widgets/dismissible.dart:526:11) flutter: #1 _DismissibleState.build (package:flutter/src/widgets/dismissible.dart:535:8) flutter: #2 StatefulElement.build (package:flutter/src/widgets/framework.dart:4334:27) flutter: #3 ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:4223:15) flutter: #4 Element.rebuild (package:flutter/src/widgets/framework.dart:3947:5) flutter: #5 StatefulElement.update (package:flutter/src/widgets/framework.dart:4413:5) flutter: #6 Element.updateChild (package:flutter/src/widgets/framework.dart:2977:15) flutter: #7 SingleChildRenderObjectElement.update (package:flutter/src/widgets/framework.dart:545(ddd78475600b5492fc67889427724d06.png)════════════════════════════════════════════════════════════════エラーとなっているコード
Widget build(BuildContext context) => Scaffold( key: _scaffoldKey, appBar: AppBar( title: Text('Todoリスト'), ), floatingActionButton: FloatingActionButton( child: Icon(Icons.add), onPressed: () { // Todo入力画面への遷移 _navigateAndInputTodo(context); }, ), body: ListView.builder( itemCount: _todos.length, //ここで表示可能なリスト数を制限しないと、表示できなくなった時にエラーになる。 itemBuilder: (BuildContext context, int index) { return Dismissible( //**ここでエラー発生** // KeyはFlutterが要素を一意に特定できるようにするための値を設定する。 key: Key(index.toString()), // onDismissedの中にスワイプされた時の動作を記述する。 // directionにはスワイプの方向が入るため、方向によって処理を分けることができる。 onDismissed: (direction) { setState(() { // スワイプされた要素をデータから削除する _todos.removeAt(index); }); // スワイプ方向がendToStart(画面左から右)の場合の処理 if (direction == DismissDirection.endToStart) { Scaffold.of(context).removeCurrentSnackBar(); Scaffold.of(context) .showSnackBar(SnackBar(content: Text("削除しました"))); // スワイプ方向がstartToEnd(画面右から左)の場合の処理 } else { Scaffold.of(context).removeCurrentSnackBar(); Scaffold.of(context) .showSnackBar(SnackBar(content: Text("削除しました"))); } }, // スワイプ方向がendToStart(画面左から右)の場合のバックグラウンドの設定 background: Container( alignment: Alignment.centerLeft, color: Colors.redAccent[700], child: Padding( padding: EdgeInsets.fromLTRB(20.0, 0.0, 0.0, 0.0), child: Icon(Icons.delete_forever, color: Colors.white)), ), // スワイプ方向がstartToEnd(画面右から左)の場合のバックグラウンドの設定 secondaryBackground: Container(color: Colors.blue), child: Card( child: ListTile( title: Text(_todos[index].title), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => DetailScreen(todo: _todos[index])), ); }), ), ); }, ), );当時参考にしたサイト
https://stackoverflow.com/questions/55792521/how-to-fix-a-dismissed-dismissible-widget-is-still-part-of-the-tree-error-in
https://stackoverflow.com/questions/58470821/a-dismissed-dismissible-widget-is-still-part-of-the-tree-in-flutter結局ほとんどのサイトで、
onDismissed
の時に、ちゃんとsetState
でオブジェクトを削除しようってことが書いていたんですよね。
ただ、当時、自分の実装もそうしており、、、下記です。onDismissed: (direction) { setState(() { // スワイプされた要素をデータから削除する _todos.removeAt(index); });結論
Dismissible
のkey
にKey(index.toString())
を指定しているのが原因でした。
これだと、途中のTODOが削除された場合、削除したindexが他のTODOで使いまわされてしまい、不整合が起きるので、エラーになっているようです。完全に初心者の過ちでした。。(恥ずかしい)
Dismissible
のkey
をObjectKey(_todos[index])
にするとTODOごとにユニークなキーになるので、エラーが発生しなくなりました。Flutter、面白いですね。勉強引き続き頑張ります。
これから頑張っていくので、githubフォローしていただけるか、Starつけていただけるとモチベーションに繋がるので、もしよかったらよろしくお願いします。
https://github.com/kazumaz/flutter-study