2020年のAndroidアプリ開発でメモリリークは考慮する必要があるのか

同僚とメモリリークの話になり自分の理解不足を痛感したので調査してみました。
業務で開発しているアプリはメモリリークの問題で困っていないですが、事前にメモリリークしないようにコードを記述しておけば未然に防げる問題なので学んでおいて損はなさそう。

前提

動作確認した環境は下記。

  • Android Studio 4.0.1
  • Pixel 3a XL(Android Emulator)
  • Android 10

また、今回はActivityがメモリリークしているか確認します。
試すパターンは下記3つ

  • 内部クラスのstatic参照
  • BroadcastReceiver解除忘れ
  • インナークラスで親をプロパティ保持

メモリリークを検知する方法

  1. アプリ起動
  2. 画面を回転する
  3. Android SutdioのProfilerから強制的にGCを走らせる
  4. Activityのfinalizeメソッドが呼ばれるか確認

正常系

まずは空のActivityの時にfinalizeメソッドは呼ばれるのか確認します。

ソースコード

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

Log.d(TAG, "onCreate")
}

override fun onDestroy() {
Log.d(TAG, "onDestroy")
super.onDestroy()
}

protected fun finalize() {
Log.d(TAG, "finalize")
}
}

上記のようなコードを書きました。
結果は下記のようになります。

結果

1
2
3
4
D/MainActivity: onCreate
D/MainActivity: onDestroy
D/MainActivity: onCreate
D/MainActivity: finalize

当たり前ですが画面回転を行ってからGCが走ると画面回転前のMainActivityインスタンスは解放されます。

内部クラスのstatic参照

非staticな内部クラスを親がstaticプロパティとして保持するパターンです。

ソースコード

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MainActivity : AppCompatActivity() {

companion object {
private var innerClass: SomeInnerClass? = null
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

Log.d(TAG, "onCreate")

if (innerClass == null) {
innerClass = SomeInnerClass()
}
}

inner class SomeInnerClass()
}

結果

1
2
3
D/MainActivity: onCreate
D/MainActivity: onDestroy
D/MainActivity: onCreate

強制的にGCした時にfinalizeメソッドが呼ばれていないのでメモリリークを起こしています。
MainActivityが破棄されても、staticプロパティが生きているので参照が残っているためGCで解放されない、という流れでしょうか。
このあたりの挙動はどうすれば確認できるか謎なので割愛。

BroadcastReceiver解除忘れ

Androidでよくある実装として、各種リスナーやレシーバーをActivityに登録して使うやり方があります。
その時、画面終了時に登録解除を忘れたりするとメモリリークするらしいので確認してみます。

ソースコード

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class MainActivity : AppCompatActivity() {

private var TAG = MainActivity::class.simpleName

private var localBroadcastReceiver: BroadcastReceiver? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

Log.d(TAG, "onCreate")
}

override fun onStart() {
super.onStart()

registerBroadCastReceiver()
}

override fun onStop() {
super.onStop()

//あえてレシーバーの登録解除をスキップする
// if (localBroadcastReceiver != null) {
// unregisterReceiver(localBroadcastReceiver)
// }
}

private fun registerBroadCastReceiver() {
localBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
}
}
registerReceiver(
localBroadcastReceiver,
IntentFilter("android.net.conn.CONNECTIVITY_CHANGE")
)
}
}

結果

1
2
3
D/MainActivity: onCreate
D/MainActivity: onDestroy
D/MainActivity: onCreate

画面回転後にGCを走らせてもfinalizeメソッドが呼ばれませんでした。
これはBroadcastReceiverがActivityの強参照を保持しているかららしいです。

インナークラスで親をプロパティ保持

ソースコード

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MainActivity : AppCompatActivity() {

private var TAG = MainActivity::class.simpleName

private lateinit var innerClass: InnerClass

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

Log.d(TAG, "onCreate")

innerClass = InnerClass()
innerClass.activity = this
}

override fun onDestroy() {
Log.d(TAG, "onDestroy")
super.onDestroy()
}

protected fun finalize() {
Log.d(TAG, "finalize")
}

inner class InnerClass {
lateinit var activity: Activity
}
}

結果

1
2
3
4
D/MainActivity: onCreate
D/MainActivity: onDestroy
D/MainActivity: onCreate
D/MainActivity: finalize

MainActivityとInnerClass間で循環参照になっているのにGCで問題なく解放されてますね・・・謎
BroadcastReceiverと事情は同じはずなのでメモリリークすると思ってました。
昔はこれでメモリリークしてたようなのですが、今現在は問題ないようです。

所感

公式のGCに関する情報を確認する限りAndroid 10でも変更が入ってたりするのでそのあたりの変更により以前に比べてメモリリークしにくくなっているのかなぁと推測します。
また、ハードウェアの進化でヒープ領域が以前に比べて大きくなったことも考慮すると、メモリリークに対してそこまで神経質にならなくても良いのではと思います。
それよりも昨今Androidアプリ開発だとアーキテクチャがどうとかクラス間が疎結合になってるとか可読性とか責務の分離とか、そう言った事柄の方が重視されてる気がします。
メモリやヒープを気にしなくてよくなった結果、コードの中身を考える余裕ができアーキテクチャが重視されるようになったと考えると、コードの中身が整ったら次は何が重視されるのだろうかとふと思いました。
全然まとまってないけど終わり。

参考サイト

https://www.geeksforgeeks.org/memory-leaks-in-android/
https://qiita.com/amay077/items/3df253f66724c56faaff
https://tomokey.blogspot.com/2011/05/android.html

Android 10でIntentを使ってキャプチャした画像を外部フォルダに任意のディレクトリを作って保存する

今更ながらAndroid 10以降でIntent(MediaStore.ACTION_IMAGE_CAPTURE)を使ってキャプチャした画像を、各メディア直下のディレクトリではなくアプリ専用のディレクトリを作って保存する方法について調べました。

準備

まずxmlの設定から。AndroidManifest.xmlをいじります。
AndroidManifest.xml<application>タグ内にFileProviderの記述を追加します。

1
2
3
4
5
6
7
8
9
10
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">

<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider"/>
</provider>

<meta-data>タグ内で参照しているfile_provider.xmlはres/xml配下に作成し中身は下記のようになっています。

1
2
3
<paths>
<external-path name="images" path="Pictures/Hoge" />
</paths>

Uri取得

次に画像の保存先をMediaStoreから取得します。

1
2
3
4
5
6
7
8
9
10
val fileName = "hoge.jpg"
val values = ContentValues()
values.put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
values.put(
MediaStore.Images.Media.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES + File.separator + "Hoge"
)
values.put(MediaStore.Images.Media.MIME_TYPE, "image/*")
val contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
imageUri = contentResolver.insert(contentUri, values)

RELATIVE_PATHfile_provider.xmlで記述したpathの値と同じにします。
imageUriはプロパティとして保持しておきonActivityResultで再利用します。

Intentを投げる

事前に取得しておいたUriをIntentにくっつけて投げます。

1
2
3
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri)
startActivityForResult(intent, REQUEST_CODE)

カメラアプリがいくつか候補に出てくると思うので好きなアプリを選択して写真を撮ります。

戻り値を受け取る

ImageDecoderというクラスを使ってBitmapを生成して、ImageViewにセットすれば終わりです。

1
2
3
4
5
if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK) {
val source = ImageDecoder.createSource(contentResolver, imageUri!!)
val bitmap = ImageDecoder.decodeBitmap(source)
findViewById<ImageView>(R.id.image).setImageBitmap(bitmap)
}

感想

公式動画やブログでも再三言われていたことですが、Google的にはファイルパスは極力使わないでUriを使って画像を扱って欲しいのだなと改めて思う内容でした。
近い将来、動画をファイルパスでしか読み込めないライブラリたちがUriをサポートしたらファイルパスの参照も禁止になってしまうのかもしれません。
そう考えるとできるだけファイルパスに依存しない実装にしておくのが吉なのでしょう。知らんけど。

参考サイト

Androidで動画の再生速度を変更する

MediaPlayerの動画再生速度をいじりたかったけど、アプリのminSdkVersionが21でsetPlaybackParamsを使えない(左記メソッドはAPI level 23以上必要)時の対処法について書きます。

結論から言うとExoPlayerを使いました。
既に画面がある場合は辛いかもですが新規で動画プレイヤー画面を作る場合は、MediaPlayer・ExoPlayerどちらを使っても実装工数は変わらない印象です。
機能的にはExoPlayerの方が柔軟にカスタマイズできて便利そうです。詳しくはこちら
ExoPlayerの場合はsetPlaybackParametersを使って動画の再生速度を変更できました。

参考サイト

https://stackoverflow.com/questions/10849961/speed-control-of-mediaplayer-in-android
https://codelabs.developers.google.com/codelabs/exoplayer-intro/#0

Fabric Crashlytics SDKからFirebase Crashlytics SDKに乗り換えるときのTODO

公式アナウンス

どうやらFabric製Crashlyticsが使えるのも今年の11/15までになりそうなので、Firebase製のCrashlyticsに移行してみました。

前提

  • Fabric製Crashlyticsを現在進行形で使っている
  • Firebaseとの連携が住んでいる

TODO

移行ドキュメントを見る限り、コードの置き換えは必要なものの機械的に置き換えていけばそんなに時間掛からなそうな印象でした。
また、移行ドキュメントの日本語訳がまだないようなので英語で確認する必要がありました。
TODOは下記。

  1. SDKの入れ替え
  2. Applicationクラスの書き換え
  3. Crashlyticsクラスを使ってる箇所の書き換え

それぞれ詳しく見ていきましょう。

1. SDKの入れ替え

https://firebase.google.com/docs/crashlytics/upgrade-sdk?hl=en&platform=android#add-crashlytics-sdk

ルートディレクトリとappディレクトリ内にあるbuild.gradleをいじります。
当たり前ですが、この修正以降全てのTODOが完了するまでビルドが通らなくなります。

2. Applicationクラスの書き換え

https://firebase.google.com/docs/crashlytics/upgrade-sdk?hl=en&platform=android#fabric_sdk_2

Crashlyticsの初期化処理を書き換えます。
Fabric API keyは不要になりますのでAndroidManifest.xmlから削除しておきましょう。

3. Crashlyticsクラスを使ってる箇所の書き換え

https://firebase.google.com/docs/crashlytics/upgrade-sdk?hl=en&platform=android#the_new_package_and_classname_for_is_comgooglefirebasecrashlyticsfirebasecrashlytics

コード上の各所で使われていて影響範囲は広かったのですが、機械的に直せそうだったので下記のような一括置換を行いました。

インポート文修正

1
2
3
4
5
// Before
import com.crashlytics.android.Crashlytics

// After
import com.google.firebase.crashlytics.FirebaseCrashlytics

インスタンスの取得方法変更

1
2
3
4
5
// Before
Crashlytics.log()

// After
FirebaseCrashlytics.getInstance().log()

後は動作確認して終わり。

教えることの大切さを知る

はじめに

最近、この記事が自分の中で空前の大ブームになっている。
今までなんとなく人に教えることやアウトプットすることは大事だと思っていたが、なぜの部分が腑に落ちていなかった。
この記事を読んで、なるほど!という感じで腹落ちした気がした。後は偉人たちもやってるよ、という部分に説得力を感じてしまったけどこれは日本人特有の釣られ方かもしれない。

自分にできそうなこと

学びたいことは主にITに関することという前提に立つと、記事を読んで自分に出来そうだと思った教える系の行為は

  • ラバーダッキング
  • ブログを書く
  • ジャーナリング(雑なブログ)

あたりかなぁ。他人に教えることが一番とは書いてあるものの、学習中の事柄なので上手に説明することが難しいこともありそう。と考えると他人に教えるのは気が引ける。
まずは上記3つをやってみてチャンスがあれば社内勉強会とか社外LTとかで他人に話す・教える行為もすると良さそう。
やる意思はあるけどとても億劫に感じるのはアウトプットが習慣化できていないことが問題な気がするので、まずは量をこなしてアウトプット量を増やすのが大事そうな気がする。
がんばるぞい。

SharedPreferencesの保存速度を計測してみた

SharedPreferencesの処理はUIスレッドに割と書いたりしますが、保存する回数や文字列の長さが増えて問題ないのか確かめてみました。
下記のようなコードで wordCountの値を調整する感じです。
5回計測を行い平均値を出してみます。

1
2
3
4
5
6
val wordCount = 1

(0 until 10_000).forEach {
val prefs = getSharedPreferences("hoge", Context.MODE_PRIVATE)
prefs.edit().putString("save_value", "a".repeat(wordCount)).apply()
}

実行環境

  • Androidエミュレータ(Pixel2 API 28)
  • RAM 1536MB

結果

wordCount=1

(1907 + 2065 + 1907 + 1833 + 1917) / 5 = 1925.8ms

wordCount=100

(1818 + 1742 + 1806 + 1779 + 1797) / 5 = 1788.4ms

wordCount=10000

(3542 + 3694 + 3708 + 4433 + 3915) / 5 = 3858.4ms

おまけ(ループ回数=1_00_000, wordCount=100)

(13859 + 12701 + 12809 + 12754 + 13489) / 5 = 13122.4ms

感想

保存する回数や文字列の長さにより保存にかかる時間は増えました。
しかし文字数は1万文字を1回保存するのに0.4msぐらいなのでUIスレッドで実行しても問題なさそうです。
WEB APIから受け取ったJsonをString配列としてキー名を分けてSharedPreferencesに保存するとかそれくらいやばいことをすれば話は別ですが・・・
結論、SharedPreferencesの保存処理はUIスレッドを考慮しなくていいくらい早い。

Kotlinのインライン関数のパフォーマンスを測ってみた

インライン関数の存在自体は前から知っていたのですが、使うことでどれくらいパフォーマンスが向上するのかわからなかったので調べてみました。

測定環境

  • OS -> Mac 10.14.6
  • CPU -> Intel Core i9(2.9GHz)
  • メモリ -> 32GB

インライン関数とは

そもそもインライン関数とは、インライン展開を指示するような記述のある関数のことです。
インライン展開とは、呼び出す側に対象の関数の中身を記述することで、関数呼び出しにかかるオーバーヘッドを無くすようなコンパイラの動作を指します。

実験

インライン関数と非インライン関数の速度を比較するために下記のようなコードを準備しました。
3回計測して平均値を取得してみます。

1
2
3
fun hoge(x: Int, y: Int, function: (a: Int, b: Int) -> Int) {
function(x, y)
}

この関数をまずはこのまま1億回呼んでみます。

1
2
3
(1..100_000_000).map {
hoge(it, it) { a, b -> a + b }
}

速度は、(1009+963+945)/3=972.3msでした。
次にinline修飾子をつけて試してみます。

1
2
3
inline fun hoge(x: Int, y: Int, function: (a: Int, b: Int) -> Int) {
function(x, y)
}

結果は、(872+878+848)/3=866msでした。

結論

今回はMacbook Proを使ったこともあり1億回呼び出しして100ms程度の差に留まりましたが、格安スマホなどで試せば差はさらに顕著になるかと思います。
何れにせよ、ループ処理の中で高階関数をパラメータに持つ関数を呼びだす場合はインライン関数を使っておいた方がパフォーマンスが良くなることがわかりました。

参考URL

https://dogwood008.github.io/kotlin-web-site-ja/docs/reference/lambdas.html
https://qiita.com/sekitaka_1214/items/749f824e04d6fda4733c
https://qiita.com/satoru_takeuchi/items/5d5eacfd805bd5289311

MPAndroidChartのPieChartをカスタマイズしたかった

はじめに

仕事でMPAndroidChartPieChartを使う機会があったのですが、カスタマイズが思いの外時間がかかったのでメモを残しておきます。
PieChartは円グラフを表示するUIコンポーネントで、細かな制御が色々できて便利なのですが、メソッド名が直感的ではなく結局ソースコードを読んでどのプロパティを変更すれば期待した動作になるか調査しながら進めました。

やりたかったこと

まずはじめに、デフォルトのPieChartを表示すると下図のようになります。

この図に対して下記3点の変更を加えました。

  • 円グラフの太さを変更
  • テキストを中央に配置する
  • 円グラフの中に表示されてる数値を削除する

円グラフの太さを変更

PieChartの内部では、まず穴なしの円を書いた後にその円をくり貫くような実装をしています。
なので円グラフの太さを変更したい場合はくり抜く円の半径を調整すれば良いです。

1
pieChart.holeRadius = 90f

元の円の何パーセントをくり抜くかというプロパティを変更することで実現できます。デフォルト50%なので90%にすると細くなります。

テキストを中央に配置する

円グラフの中央にテキストを配置するということはよくあると思います。
PieChartとは別にビューを作れば良いだけではありますがPieChart自身も中央にテキストを配置する機能を持っています。

1
pieChart.centerText = "Test Text"

テキストカラー・サイズを変更するメソッドも生えてるので安心です。

円グラフの中に表示されてる数値を削除する

これが一番ハマりました。この数値を消すにはPieChartではなくPieDataSetのプロパティを変更する必要があります。

1
dataSet.setDrawValues(false)

PieDataSetが内部に持つmDrawValuesfalseにすることでデータを描画するときに数値を表示しないようにできます。

上記3つのプロパティを全て変更すると下図のようなグラフになります。

参考サイト

https://github.com/PhilJay/MPAndroidChart
https://weeklycoding.com/mpandroidchart-documentation/

OkHttpのリトライ無効化

状況

Retrofitで通信失敗した時にリトライしてくれてることに最近気がつきました。デフォルトで2回リトライしてくれるようです。
しかし、通信箇所によってはリトライしたくない時もあります。そんな時の対処法について調べました。

実装

OkHttpClient作成時に下記のようにretryOnConnectionFailure(false)を設定してあげるとOkHttpClientを使って通信を行う時にリトライしなくなります。

1
2
3
4
5
6
7
public static OkHttpClient createOkHttpClient(Context context) {
return new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build();
}

補足

どんな通信エラーの時でもリトライする訳ではなく、タイムアウトなどの特定のエラーの時のみリトライするようです。
詳しくはこの辺りのコードを見ると分かります。

参考URL

https://github.com/square/okhttp/pull/1259

Kotlin Reflectionに触れてみる

AndroidのAPKをアップロードする時に表示されるようになった警告について調べたら、どうやらリフレクションを使ってAndroid SDKに含まれるクラスの公開されていないメソッド、プロパティを参照すると表示される警告らしいということが分かりました。
しかし、今までリフレクションは単体テストレベルでプライベートメソッドを無理やりテストするくらいしか試したことがなく、Android SDKで公開されていない情報を本当に参照できるのか疑問に思ったので試してみました。

準備

依存解決

新しくAndroidプロジェクトを作成後、implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"を追加して、Kotlinのリフレクションライブラリをインポートする。
これでリフレクションを使う準備ができました。

クラス作成

下記のような、プライベートメソッド・プロパティを持つクラスを作成します。

1
2
3
4
5
6
data class User(val id: Int, val name: String, private val description: String) {

private fun getFullInfo(): String {
return "This user's id is $id, name is $name"
}
}


自作クラスで試す

メソッドの参照

Kotlinリフレクションを使って特定のメソッドを呼びだします。

1
2
3
4
5
6
cls.memberFunctions
.filter { it.name == "getFullInfo" }
.forEach {
it.isAccessible = true
println("This function name is ${it.name}. value is ${it.call(user)}")
}

出力

1
This function name is getFullInfo. value is This user's id is 1, name is kseito

プロパティの参照

Kotlinリフレクションを使って特定のプロパティを参照します。

1
2
3
4
5
6
cls.memberProperties
.filter { it.name == "description" }
.forEach {
it.isAccessible = true
println("${it.name} value is ${it.get(user)}")
}

出力

1
description value is I love Splatoon!


TextViewのプロパティ参照

最後にAndroid SDKに含まれるクラスです。
みんな大好きTextViewのプライベートプロパティに対してリフレクションを使ってみます。
本来ならgetText()で取得するテキストをリフレクションを使ってプロパティ参照してみます。

1
2
3
4
5
6
7
8
val textView = findViewById<TextView>(R.id.text_view)
val cls2 = TextView::class
cls2.memberProperties
.filter { it.name == "mText" }
.forEach {
it.isAccessible = true
println("${it.name} value is ${it.get(textView)}")
}

出力

1
mText value is Hello World!

無事取得できました。

サンプルソースはこちら

参考サイト

https://qiita.com/HIkaruSato/items/d9a9b0ca4b1da77221fbjkjkjkjkaaaaa
https://qiita.com/KeithYokoma/items/9e692808095acf560bc9