このドキュメントの目的
モバイルアプリはUIツールやライブラリーが充実しており、スマホ利用者は視覚的・操作的になじみやすいアプリが提供されている。とはいえ、スマホ単体で完結するモバイルアプリはほぼなく、サーバーに接続(APIなど)する事で機能要件を満たせるものばかりである。筆者もモバイルアプリ開発にかかわる機会を得たが、サーバー接続に関する情報が少なく苦労したので、備忘録の意味をふくめここに整理しておく。
使用環境
- Android 8.0+ (APIレベル26+)
- Android Studio 4.2.1
- Kotlin 1.5.10
- Retrofit 2.9.0
- gson 2.8.6
Googleの公式アーキテクチャガイド
Googleではアーキテクチャーガイドを制定して開発者に提供している。モバイルアプリの構造としては以下が規定されており、リリース審査の観点にもなるのでなるべくこのアーキテクチャーの沿ってアプリを開発すべきである。
Remote Data Sourceに着目
今回のドキュメントの目的はサーバー接続部分である。アーキテクチャーの以下の部分となるのでここに着目して記述する
アーキテクチャー上の役割
Repositoryの役割
- アプリ上位レイヤに提供するデータの格納を行う
- 状況に応じてスマホ内の永続化データまたはサーバーからのデータの取得を行う
- サーバーから取得したデータを永続化データに格納して、次の要求に備える役割も担う
Remote Data Sourceの役割
- Repositoryからの要求に応じてサーバーからのデータ所得・格納を行う。
- サーバー側はRestAPIで窓口を開けている事が多いので、Restクライアント機能を実装する
- このためにRetrofitを使う
- Remote Data Sourceのクラスは単数でも複数でもよいが、筆者は3つのクラスに分けて開発した
- サービスインターフェース:Retrofitの機能を用いてサーバーが提供するAPIを定義する
- login
- logout
- getAppData
- など
- サービスパラメータクラス:APIでやりとりするJson文字列をデータ構造として解釈するクラス
- loginReqest
- loginResponse
- logoutRequest
- など
- サービス実装クラス:ビジネスロジックを含む実装クラス。API毎にメソッド(ファンクション)を記述してRepositoryから適宜利用する
- login
- logout
- getAppData
- など
- サービスインターフェース:Retrofitの機能を用いてサーバーが提供するAPIを定義する
Remote Data Sourceの実装
サービスインターフェース
ここにRetrofitのアノテーションを用いてRestAPIのインターフェースを記述する。
以下はloginの場合
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.*
interface TestApiService {
@POST("{action}")
fun login(@Path("action") action: String, @Header("Authorization") basicAuth:String, @Body body: RequestBody): Call<ResponseBody>
}
- アノテーション
- @POST:loginはサーバー側のデータに作用するのでメソッドはPOSTを使用する
- @path:APIのURI
- @Header:リクエストヘッダーに設定するパラメータ。loginAPIはベーシック認証を使用するのでAuthorizationを定義する
- @Body:リクエストJson
- 引数
- action:APIのURIを示す文字列
- basicAuth:ベーシック認証に使用する暗号化文字列
- body:リクエストJsonの文字列
- 戻り値
- Call:結果はCallにもどる
サービスパラメータクラス
ここに各APIで使用するReq/ResのJsonを格納するKotlinデータクラスを定義する
- jsonに含まれるリストはmutableListOfで記述する
- jsonが階層構造の場合、親クラスのフィールドに子クラスを定義することで記述できる
class TestParameters {
/**
* logoinリクエスト
*/
class LoginReqData {
var appVer = ""
var mailID = ""
var password = ""
}
/**
* logoinレスポンス
*/
class LoginResData {
var errors = mutableListOf<ResError>()
var access_token = ""
var refresh_token = ""
var result = ResResult_login()
}
/**
* 共通エラー
*/
class ResError {
var code = 0
var message = ""
var field = ""
var title = ""
}
/**
* login正常
*/
class ResResult_login {
var message = ""
var title = ""
var data = ResData_login()
}
}
サービス実装クラス
ここにRepositoryから起動されるファンクションを定義しAPI起動する前処理・後処理を記述する。
- 前処理:Repositoryから受け取ったデータクラスをjsonにする
- モバイルアプリの場合、ActivityやFragmentから渡された項目をデータクラスにRepositoryで格納している
- 後処理:サーバーから受け取ったJsonをデータクラスにしてRepositoryに返却する
- モバイルアプリの場合、Repositoryで永続化データとして格納したり、ActivityやFragmentから参照され画面に表示される
import android.util.Log
import com.google.gson.Gson
import okhttp3.MediaType
import okhttp3.RequestBody
import retrofit2.Retrofit
class TestRemote {
private val TAG = TestRemote::class.java.simpleName
// Retrofit本体
private val retrofit = Retrofit.Builder().apply {
baseUrl("https://hogehoge.com/api/")
}.build()
// サービスクラスの実装オブジェクト取得
private val service = retrofit.create(TestApiService::class.java)
// 通信全体で利用するMediaType (Json)
private val mediaTypeJson: MediaType = MediaType.parse("application/json; charset=utf-8")!!
//Basic認証で使用するワードのBase64文字列
private val basicAuth = "Basic xxxxxYYYYYzzzzz1111122222"
fun login(loginReq: TestParameters.LoginReqData): TestParameters.LoginResData {
val TAG_fun = "login"
var loginRes = TestParameters.LoginResData()
//ReqデータクラスをJsonに変換
val jsonString = Gson().toJson(loginReq)
//リクエスト設定
val request = RequestBody.create(mediaTypeJson, jsonString)
Log.d("$TAG $TAG_fun", "request.contentType = ${request.contentType().toString()}")
Log.d("$TAG $TAG_fun", "request.content = ${request}")
//retrofitの動作定義
val apiAction = service.login("Login", basicAuth, request)
Log.d("$TAG $TAG_fun", "scope.launch")
//リクエスト実行
val response = apiAction.execute()
Log.d("$TAG $TAG_fun", "post.request().headers() = ${apiAction.request().headers()}")
Log.d("$TAG $TAG_fun", "responseBody.isSuccessful= ${response.isSuccessful}")
Log.d("$TAG $TAG_fun", "responseBody.headers = ${response.headers()}")
Log.d("$TAG $TAG_fun", "responseBody.code = ${response.code()}")
var resCode = response.code()
if (resCode != 200) {
//Error応答の場合
response.errorBody()?.let {
//応答の一時保存
val jsonString = it.string()
Log.d("$TAG $TAG_fun", "response.errorBody = ${jsonString}")
//応答Jsonのパース
// URL不正・サーバー無応答など、errorBodyがjsonではない場合があるのでtry-catchをかける
try {
loginRes = Gson().fromJson(
jsonString, //ここにit.string()をいれてはいけない。一旦文字列にしないとNull関連例外になる
TestParameters.LoginResData::class.java
)
Log.d("$TAG $TAG_fun", "loginRes.errors = ${loginRes.errors}")
} catch (e: Exception) {
Log.d("$TAG $TAG_fun", "errorBody is not Json-Strings")
}
}
} else {
//正常応答の場合
response.body()?.let {
//応答の一時保存
val jsonString = it.string()
Log.d("$TAG $TAG_fun", "response.responseBody = ${jsonString}")
//応答Jsonのパース
loginRes = Gson().fromJson<TestParameters.LoginResData>(
jsonString,
TestParameters.LoginResData::class.java
)
}
}
//loginResの項目を辿ればサーバーが返却した値が取得できる。
Log.d("$TAG $TAG2", "refresh_token = ${loginRes.refresh_token}")
Log.d("$TAG $TAG2", "expiresIn = ${loginRes.expiresIn}")
return loginRes
}
}
まとめ
初のモバイルアプリ開発ということで気負う部分も多かったが、サーバー接続、データアクセスなどはWebアプリケーションとそれほど差異はない。
UI/UXにコツやセンスは必要になるが、アプリケーションの根幹は変わらない事は認識できたので、今後も恐れずモバイルアプリ開発に挑戦したいと思う。