Swift の勢いで Kotlin 書いたら 3 行で 3 つのクラッシュを起こした事に対する反省文
2017 年アクトインディ アドベントカレンダー が始まりました。今年で 3 年目になります。1年目、2年目は技術寄りの投稿が多かったのですが、今年は、社員全員に公募を募り、様々なテーマで書いて行きますので、是非、今後の記事も楽しみにしていただければと思います。
1日目の記事を担当する namikata です。アプリエンジニアをしています。宜しくお願いします。今年最初の記念すべき記事は、反省文からのスタートです!自戒の念を込めてまとめて行きたいと思います。
iOS と Android の両方の開発に関わらせてもらっていますが、しばらく Swift で開発を行った後、Swift の余韻が残った状態で Kotlin の開発にシフトチェンジしたら、たった 3 行のコードで 3 種類のクラッシュ を発生させてしまう悪行を働いてしまいました。今回はそのコードを例にあげ Swift エンジニアが Kotlin で開発する際の心構えの基本的なところを伝えていければと思っています。
サンプルは、緯度経度の情報を元に都道府県を取得する以下のコードです。これには 3 つの重要なエッセンスが含まれています。
val geocoder = Geocoder(context) val addressList = geocoder.getFromLocation(lat, lng, 1) val adminArea = addressList.first().adminArea
Kotlin は Null 安全だけど Java が混じった Kotlin は Null 安全ではない
まず一つ目は getFromLocation メソッドです。 getFromLocation は引数に緯度、経度、maxResults を取り List<Address>
を返却してくれるメソッドです。val addressList = geocoder.getFromLocation(lat, lng, 1)
と型推論を利用して、型の記述を省略していますが addressList の型は何になっているでしょうか?
getFromLocation は List<Address>
を返却してくれるので、もちろん List<Address>
型となっています。その為、以下のように書き換える事ができます。
val geocoder = Geocoder(context) val addressList: List<Address> = geocoder.getFromLocation(lat, lng, 1) val adminArea = addressList.first().adminArea
コンパイルエラーも起きません。しかし、実態は違います。 getFromLocation のコードを見ると、以下のように RemoteException エラーの際には null が返却されるのが分かります。
public List<Address> getFromLocation(double latitude, double longitude, int maxResults) ... 省略 ... try { ... 省略 ... } catch (RemoteException e) { Log.e(TAG, "getFromLocation: got RemoteException", e); return null; } }
Java で書かれたコードを呼び出す際には、このような事が多くある為、対策としては、呼び出すメソッドを確認して null が返却されるかどうかをきちんと把握して書く必要があります。また、このようなケースで型推論を利用すると val addressList: List<Address>
となる為 null が返却される場合には以下のように Nullable である事が分かるように書いてあげるのがいいかと最近感じています。
val geocoder = Geocoder(context) val addressList: List<Address>? = geocoder.getFromLocation(lat, lng, 1) val adminArea = addressList?.first().adminArea
val addressList: List<Address>?
と書く事で addressList は Nullable である事がパッと見ても分かるようになり val adminArea = addressList.first().adminArea
は addressList が Nullable の為、コンパイルエラーを出してくれるようになります。Swift の場合は、あまり意識せずにコードを書いていても、ほとんど NullPointer Exception に遭遇することはありませんでしたが、 Android では良く遭遇しました。
例外をスルーする first() メソッド
次の問題点は addressList.first() の部分です。
Swift では空のコレクションに対して first や last を呼び出すと nil が返ります。
[].first // nil
Kotlin の場合は空のコレクションに対して first() を呼び出すと例外が発生する為、クラッシュします。
listOf<Int>().first() // NoSuchElementException: List is empty
何一つ疑う事なく空になるコレクションに対して first() 使いまくってました。ごめんなさい。Kotlin では Swfit の first に相当する firstOrNull() があるので、空になる可能性のあるコレクションに対しては firstOrNull() を使いましょう。
listOf<Int>().firstOrNull() // null
先ほどのコードを改修すると以下のようになります。
val geocoder = Geocoder(context) val addressList: List<Address>? = geocoder.getFromLocation(lat, lng, 1) val adminArea = addressList?.firstOrNull()?.adminArea
リリースしてみないと中々発見できないクラッシュ
Geocoder#getFromLocation の実行で IOException: Service not available が発生したというクラッシュレポートが上がってきました。調べてみると Geocoder の getFromLocation は メモリ不足やアプリの強制終了などの理由により IOException: Service not available
になることがあるそうです。
android - Service not available while calling geoCoder.getFromLocation() - Stack Overflow
対策はいくつかありますが、今回は例外が発生したらレポートを送信して、アプリがクラッシュしないように try catch するようにコードを変更してみました。
val geocoder = Geocoder(context) val addressList: List<Address>? = try { geocoder.getFromLocation(latLng.latitude, latLng.longitude, 1) } catch (e: IOException) { // 原因調査に必要な情報をレポートとして記録する } val adminArea = addressList?.firstOrNull()?.adminArea
Kotlin に限った話ではないですが、このような事例をリリースにテストで全て網羅するのは非常に難しいです。Crashlytics などのツールを利用して、クラッシュレポートを分析できるようにしておき、リリースに関しては、Google Play Console の段階リリースを利用して、クラッシュ発生の有無を確認しながら、徐々にリリース対象を広げていく方法で、被害を最小限に抑えるアプローチが必要になってくると思います。
まとめ
Swift エンジニアが初めて Kotlin でコードを書く際に気をつけるポイントを 3 つの事例でまとめてみました。
- Kotlin は Null 安全だから Android Studio のチェック機能に従って実装していれば NullPointerException は発生しないといった事はありません。
- ライブラリのメソッドを参照する際は、メソッドの中身を確認し Null が返却されるかどうかを確認する事
- Null が返却される場合には、変数に代入する際は型推論を避け、Nullable な型として宣言する事
- ドキュメントを読みましょう。当たり前の話ですが、言語が変われば仕様も異なるので、推測で書いてはダメだよといった話です。すいません、自分もドキュメント読み直します
- Google Play Console の段階リリースを利用して、クラッシュ発生の有無を確認しながら、リリースの対象を増やして行きましょう。特に Android 端末はメーカー各社がカスタマイズを施しているので、端末依存の不具合も少なくありません。リリースしてから発覚するクラッシュを想定して、段階リリースを使って行くのが良いかと思います。
Android での二度押し防止施策
アクトインディ Advent Calendar 2016 23日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^
Kotlin 1.0.5 minSdkVersion 19 targetSdkVersion 25
本日の記事は Android アプリでの二度押し防止施策について書きたいと思います。 API と通信を行うアプリを作成していると、保存や送信ボタンの連続タップ時の例外的な処理を考慮に入れる必要が出てくると思うので、連打防止施策のちょっとした tips を紹介したいと思います。 ProgressDialog を表示して、処理が完了したら Dialog を消すといった方法も考えましたが Dialog が表示されるまでのちょっとした間でも、タップ処理が連続して反応してしまう為、ボタンをタップしたら 0.5 秒間 isEnabled = false にして、ボタンのタップ処理を無効にする方法を採用しました。 ImageButton や Button は View を継承しているので View の Extension としてタップを禁止する処理を入れています。
- ViewExtension.kt
/** * 二度押し防止施策として 0.5 秒間タップを禁止する */ fun View.notPressTwice() { this.isEnabled = false this.postDelayed({ this.isEnabled = true }, 500L) }
連続タップを禁止したいボタンの setOnClickListener で以下のように呼び出します。
postButton.setOnClickListener {
postButton.notPressTwice()
// 後続の処理を書く
}
iOS エンジニアが Android を開発する時のコンポーネントやウィジェットの比較表
アクトインディ Advent Calendar 2016 21日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^
今までは iOS のアプリ開発をしていたのですが Android アプリも作成することになり iOS のコンポーネントや各ウィジェットが Android で何に対応するのか最初に開発する時に知りたかったので、簡単なところだけですがまとめてみました。
kotlin
アプリケーションのライフサイクル
iOS | Android |
---|---|
AppDelegate | Application |
アプリケーションのライフサイクルを管理するクラスです。プロジェクト作成時に iOS の場合は AppDelegate が作成されますが Android の場合は Application クラスを継承したサブクラスを自分で作成します。
class SampleApplication : Application() { override fun onCreate() { super.onCreate() } }
画面のライフサイクル
iOS | Android |
---|---|
ViewController | Activity |
iOS の場合は画面の遷移は NavigationController に ViewController がスタックされていきます。Android も同様に Activity が Task にスタックされていきます。Android には Modal といった概念はありません(noHistory などを指定することでスタックしないといった設定を行えます)
View
iOS | Android |
---|---|
Xib | Layout(xml) |
iOS の Xib に相当するものが Android では xml で定義する layout ファイルになります。StoryBoard の概念は Android にはありません。
iOS | Android |
---|---|
UIView | View, ViewGroup |
iOS の場合は View は単一でもグループとしても振る舞うことができますが Android は View は常に単一で、複数のView を持つクラスは ViewGroup となっています。 ViewGroup には RelativeLayout, FrameLayout, LinearLayout があり、組みたいレイアウト構成により使い分けます。
iOS | Android |
---|---|
UITableView | ListView, RecyclerView |
Android でリスト表示したい場合には ListView か RecyclerView を利用します。どちらもリスト表示ができ RecyclerView の方がセル毎にレイアウトファイルを分けたりと柔軟に対応できるイメージがあります。
iOS | Android |
---|---|
UICollectionView | RecyclerView, ViewPager |
横スクロールするリストの表示には RecyclerView を利用します。ペラペラとページめくりしたい場合は iOS の場合は UICollectionView の pagingEnabled を true にすると実現できますが RecyclerView にはそのように機能がない為、ページめくりをしたい場合は ViewPager を利用します。
ウィジェット
iOS | Android |
---|---|
UIButton | Button, ImageButton |
iOS では、テキストだけのボタンも画像だけのボタンも UIButton を利用しますが Android では、画像のみのボタンには ImageButton , テキストを表示したい場合には Button を利用します。
iOS | Android |
---|---|
UITextField, UITextView | EditText |
iOS では、 1 行のテキストを扱う場合には UITextField , 複数行のテキストを扱う場合には UITextView を利用しますが、 Android は EditText のプロパティで 1 行か複数行かを制御します。Android で 1 行のテキストを扱う場合は EditText に inputType="text"
を指定します。複数行のテキストを扱う際に layout_height="wrap_content"
を指定した場合のレイアウトの高さは 1 行分なので、レイアウト調整では minLines を指定して何行分のレイアウトを確保するか指定してあげます。
iOS | Android |
---|---|
UILabel | TextView |
iOS では UILabel の numberOfLines に 0 にすることで複数行の可変として扱いますが Android は特にそのような指定は不要です。行数を制御したい場合は maxLines を指定してやります。
iOS | Android |
---|---|
UIImageView | ImageView |
iOS では、画像を非同期で取得する有名なライブラリは SDWebImage だと思いますが Android の場合は Glide と Picasso が有名です。 Glide はメソッド数が多いですが、画像の向き情報( rotate ) を考慮して表示してくれるので便利です。 Picasso も次期バージョンで対応するといった話を聞きました。
iOS | Android |
---|---|
DatePicker | DatePicker |
iOS の DatePicker はドラム型の表示ですが Android の DatePicker はカレンダーで表示されます。 spinner を指定すればドラム型で表示されるようになった気がしますが試していません。
iOS | Android |
---|---|
Picker | NumberPicker |
年月日を扱うときは DatePicker を利用しますが、年月だけを扱いたい場合などは NumberPicker を利用します。
アラート
iOS | Android |
---|---|
UIAlertController | AlertDialog |
Android でアラートを表示する場合は AlertDialog を利用します。AlertDialog を Activity から呼び出してアラートを表示することができますが、 Activity は、画面の回転や OS のメモリ不足などで強制的に Kill されることがある為 DialogFragment を継承した Fragment で実装することが推奨されています。 iOS にはないトースト表示として Activity などの context を必要としない Toast クラスもあります。
Kotlin + RecyclerView で OnItemClick を実装する
アクトインディ Advent Calendar 2016 20日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^
本日の記事では Kotlin での RecyclerView の実装方法について紹介したいと思います。ListView では、リストの要素をタップした時の処理は OnItemClickListener に処理を書くと思いますが RecyclerView には OnItemClickListener をセットすることができない為、独自にタップした時の処理を実装する必要があります。
今回は RecyclerView の Adapter にメソッドを引数として渡す方法で実装してみたいと思います。サンプルのソースは Github に公開しています。KotlinRecyclerViewSample
やりたい事
- MainActivity で RecyclerView のアイテムをクリックすると DetailActivity に遷移する
- タップしたアイテムのタイトルが DetailActivity に表示される
実装方法
- RecyclerView の Adapter を作成する
- MainActivity で RecyclerView をセットアップする
1. RecyclerView の Adapter を作成する
RecyclerView の Adapter を作成します。ポイントはコンストラクタの引数に itemClick を宣言しているところです。今回の例では item をタップした時の処理は MainActivity でメソッドを定義し Adapter の引数として渡すことで、Activity の遷移の実装を行なっています。itemClick には List の要素の itemName を渡すようにしているので、 MainActivity から DetailActivity に遷移する際に必要となる itemName をメソッドを定義する MainActivity で取得できるようになっています。
class ItemListAdapter(val items: List<String>, val itemClick: (String) -> Unit) : RecyclerView.Adapter<ItemListAdapter.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false) return ViewHolder(view = view, itemClick = itemClick) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.setUp(items[position]) } override fun getItemCount(): Int { return this.items.count() } class ViewHolder(view: View, val itemClick: (String) -> Unit) : RecyclerView.ViewHolder(view) { private val textView: TextView by bindView(R.id.item_text_view) fun setUp(itemName: String) { this.textView.text = itemName this.itemView.setOnClickListener { itemClick(itemName) } } } }
item.xml は以下のようになっています。
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/item_text_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" /> <View android:layout_alignParentBottom="true" android:background="@android:color/black" android:alpha="0.1" android:layout_width="match_parent" android:layout_height="1dp" /> </RelativeLayout>
- MainActivity で RecyclerView をセットアップする
あとは Adapter をセットする時にリストをタップした時の処理を引数に渡してあげることで完成です。
class MainActivity : AppCompatActivity() { private val recyclerView: RecyclerView by bindView(R.id.recycler_view) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val adapter = ItemListAdapter(items = listOf("Apple", "Pineapple", "Pen")) { itemName -> val intent = Intent(this, DetailActivity::class.java) intent.putExtra("itemName", itemName) this.startActivity(intent) } this.recyclerView.adapter = adapter } }
class DetailActivity : AppCompatActivity() { private val textView: TextView by bindView(R.id.text_view) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_detail) this.title = "DetailActivity" this.textView.text = intent.getStringExtra("itemName") } }
Kotlin + Retrofit2 + Gson で API 通信を実装する
アクトインディ Advent Calendar 2016 18日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^
Kotlin 1.0.5 minSdkVersion 19 targetSdkVersion 25
この記事では Retrofit を使って API 通信をする際の、 http ヘッダーの追加方法や json を独自のモデルへ変更する方法などの基本的なところを書きたいと思います。iOS の開発を担当していた頃は Swift + Alamofire + ObjectMapper で API 通信を実装していたのですが、 Retrofit もとても分かりやすく、導入が容易で素晴らしいライブラリだと思いました。
- Retrofit のリクエストを生成する generater クラスを作成する
- Debug ビルド時には request と response のログを出力する
- api の json の形式に合わせて Gson の NamingPolicy を設定する
- http ヘッダー を追加する
- 401 エラー時の refreshToken を利用した token の更新方法
1. Retrofit のリクエストを生成する generater クラスを作成する
Retrofit では API のリクエスト先のエンドポイントを Interface で定義します。
interface SampleService { @GET("users/{id}") fun getUser(@Path("id") id: Int): Call<User> }
定義した Interface から API のリクエストを生成するクラスを作成しておくと、何かと便利なので、まずは generator クラスを作成します。
class RetrofitServiceGenerator { companion object { fun createService(): SampleService { val apiUrl = "https://api.sample.com/" val retrofit = Retrofit.Builder() .baseUrl(apiUrl) .build() return retrofit.create(SampleService::class.java) } } }
利用する時は val service = RetrofitServiceGenerator.createService()
で service を作成し service.listRepos(user = "user")
とします。あとは非同期でリクエストを投げたい場合は enqueue 、同期の時は execute で API をコールすることができます。
2. Debug ビルド時には request と response のログを出力する
開発中は request と response のログが見れた方が便利なので、Debug ビルドの時はログを出力するように設定します。Retrofit はデフォルトで OkHttp Client を利用しているので OkHttp の interceptor を追加してあげる形になります。 build.gradle に compile 'com.squareup.okhttp3:logging-interceptor:3.3.1'
の追加が必要です。
class RetrofitServiceGenerator { companion object { fun createService(): SampleService { val apiUrl = "https://api.github.com/" val client = builderHttpClient() // OkHttpClient に logging の設定を追加 val retrofit = Retrofit.Builder() .baseUrl(apiUrl) .client(client) // Retrofit に client を設定 .build() return retrofit.create(SampleService::class.java) } } private fun builderHttpClient(): OkHttpClient { val client = OkHttpClient.Builder() if (BuildConfig.DEBUG) { val logging = HttpLoggingInterceptor() logging.level = HttpLoggingInterceptor.Level.BODY client.addInterceptor(logging) } return client.build() } }
3. api の json の形式に合わせて Gson の NamingPolicy を設定する
Gson を利用して json で返却されるレスポンスを独自の型に変換したいと思います。Retrofit はデフォルトの設定が Gson になっており、何も指定指定なくても json レスポンスを SampleService Interface で定義した型に変換を行います。 SampleService の Call
{ id: 1, first_name: "tabeo", last_name: "gohan" }
data class User( val id: Int, val firstName: String, val lastName: String )
この時 id は json と User で一致するので問題ありませんが、 first_name, firstName は snakecase と lowerUpperCase の違いがある為、このようなケースの場合は Gson の NamingPoricy を設定してあげる必要があります。このようなパターンは良くある為、予め Gson が snakecase と lowerUpperCase を適合させる LOWER_CASE_WITH_UNDERSCORES といった指定が準備されています。
class RetrofitServiceGenerator { companion object { fun createService(): SampleService { val apiUrl = "https://api.github.com/" val client = builderHttpClient() val gson = GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create() // NamingPoricy そ指定する val retrofit = Retrofit.Builder() .baseUrl(apiUrl) .client(client) .addConverterFactory(GsonConverterFactory.create(gson)) // Retrofit に gson を設定 .build() return retrofit.create(SampleService::class.java) } } ... 省略 ... }
4. http ヘッダー を追加する
http ヘッダーに token 情報を追加して API を call する場合など、 http ヘッダーに情報を追加するケースがあると思います。 http ヘッダーを追加する場合も logging と同様に OkHttp の Interceptor を作成して追加を行います。
class RetrofitServiceGenerator { companion object { fun createService(): SampleService { ... 省略 ... } private fun builderHttpClient(): OkHttpClient { val client = OkHttpClient.Builder() .addInterceptor(BearerAuthenticationInterceptor()) // token を追加する interceptor if (BuildConfig.DEBUG) { val logging = HttpLoggingInterceptor() logging.level = HttpLoggingInterceptor.Level.BODY client.addInterceptor(logging) } return client.build() } } }
class BearerAuthenticationInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain?): Response? { chain ?: return null val accessToken = "token" // アプリ内で保持している token をセットする val request = chain.request().newBuilder() .header("Authorization", "Bearer ${accessToken}") .build() return chain.proceed(request) } }
5. 401 エラー時の refreshToken を利用した token の更新方法
OkHttp はレスポンスのコードが 401 の場合に http ヘッダーを更新して再リクエストできる Authenticator という便利なクラスが用意されています。 Authenticator クラスを継承したサブクラスを作ることで 401 エラーが返却された際の処理を実装することができます。
class TokenAuthenticator : Authenticator { private var count = 1 override fun authenticate(route: Route, response: Response): Request? { // 3 回リトライを行う。 return null で authenticate メソッドの loop から抜ける if (this.retryCount(response = response) > 3) { return null } // アプリ内で保持している refreshToken val refreshToken = "refreshToken" // refreshToken を利用して token を更新する val newToken = this.updateToken(refreshToken = refreshToken) ?: return null return response.request().newBuilder().header("www-Authorization", "Bearer ${newToken}")?.build() } private fun updateToken(refreshToken: String): String? { // Retrofit の execute (同期)メソッドで token 更新のリクエストを行う return newToken } private fun retryCount(response: Response): Int { response.priorResponse()?.let { count += 1 } return count } }
作成した TokenAuthenticator を RetrofitServiceGenerator に反映した完成版がこちらです。
class RetrofitServiceGenerator { companion object { fun createService(): SampleService { val client = builderHttpClient() val gson = GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create() val apiUrl = "https://api.github.com/" val retrofit = Retrofit.Builder() .baseUrl(apiUrl) .addConverterFactory(GsonConverterFactory.create(gson)) .client(client) .build() return retrofit.create(SampleService::class.java) } private fun builderHttpClient(): OkHttpClient { val client = OkHttpClient.Builder() .addInterceptor(BearerAuthenticationInterceptor()) .authenticator(TokenAuthenticator()) if (BuildConfig.DEBUG) { val logging = HttpLoggingInterceptor() logging.level = HttpLoggingInterceptor.Level.BODY client.addInterceptor(logging) } return client.build() } } }