Android App 開發實戰系列 Part 2. 資料來源 API

Part1. 我們從專案的起源、需求、設計和架構以及套件講起,Part2. 我們就開始來實作,首先先從「資料來源 API」開始講解和實作。

完整程式碼 https://github.com/enginebai/MovieHunt 已經釋出,可以下載程式碼邊看碼邊學習,歡迎給星 支持。這一系列文章是有連貫性的,如果還沒看過前面文章,建議先去看過前面的章節。

這章節對應到 Part 1. 的 MVVM Architecture 架構圖就是底層 Model 的 Remote:

資料來源

我們的 MovieHunt app 的電影資料是採用 The Movie Database (以下簡稱 TMDB ) 當作來源,在使用這個 API 之前我們要先去 https://www.themoviedb.org/settings/api 申請一組 Developr API Key。

申請好後複製 API Key,回到專案在 buildSrc/src/main/kotlin/ 目錄底下新增檔案 ApiKey.kt,然後加上下面的一行程式碼,其中 90c0****655 就是 Developer API Key,要替換成你申請的 API Key。

const val TMDB_API_KEY = "\"90c0*******************655\""

你可能會好奇為何要加反斜線和雙引號,等等會講解到,先照著做直接複製貼上。

重要:這個檔案因為包含機密的 API Key,所以我們不會加到版本控管裡面,所以要從 .gitignore 裡面排除掉,自己要好好保管這 Key。

RESTful API library – Retrofit 

接下來我們要開始使用接 API,我們會使用一個在 Android 開發中知名的套件 Retrofit,首先我們先針對 Retrofit 做一些基本的設定(import dependency 就不贅述了):

1. 設定 Base URL

我們會在我們的 Build Script Config.kt 裡面設置我們的 Base URL:

object Config {
const val API_ROOT = "\"https://api.themoviedb.org/3/\""
const val IMAGE_API_ROOT = "\"https://image.tmdb.org/t/p/\""
}
defaultConfig {
...
buildConfigField("String", "API_ROOT", Config.API_ROOT)
buildConfigField("String", "TMDB_API_KEY", TMDB_API_KEY)
buildConfigField("String", "IMAGE_API_KEY", Config.IMAGE_API_ROOT)
...
}
view raw Config.kt hosted with ❤ by GitHub

加完之後要執行 Gradle Sync + Make Project 一次讓設定可以加入到 BuildConfig

這邊要注意,API_ROOT / IMAGE_API_ROOT 跟之前加入的 TMDB_API_KEY 一樣,要加反斜線和雙引號,原因在於 buildConfigFiels() 方法有三個參數,分別是變數型態、名稱和值(字串型態),如果去看這個方法的原始碼:

public void buildConfigField(
    @NonNull String type, 
    @NonNull String name, 
    @NonNull String value
)

可以看到最後一個 value 是字串型態,如果你今天要設定一個數字,是要寫成 "1234"如果要設定這個變數是 String,需要多一組雙引號,也就是我們另外加的反斜線和雙引號。

Retrofit 就可以增加 baseUrl(url: String)

Retrofit.Builder()
    .baseUrl(BuildConfig.API_ROOT)
    .build()

(你可以嘗試把 API_ROOT 改成 = “http://api…” 然後編譯看看,看會發生什麼事情,就更可以理解上面說要多加一組雙引號的原因了。)

你可能會問「這個網址為何要在 Build Script 設置呢?」為何不直接寫在程式裡面呢?這是因為考量到設置的靈活性,有一種常見的情況是我們有不同的 API 環境,Base URL 要分 Staging / Production 兩個不同的網址,這時候我們就可以使用 Android 的 build variants 來區分(如下面範例),這時候把 URL 寫在 Build Script,這樣的做法是比較具有彈性的。

object Config {
const val API_ROOT = "\"https://api.themoviedb.org/3/\""
const val STAGING_API_ROOT = "\"https://staging-api.themoviedb.org/3/\""
}
productFlavors {
create("staging") {
buildConfigField("String", "API_ROOT", Config.STAGING_API_ROOT)
}
create("production") {
buildConfigField("String", "API_ROOT", Config.API_ROOT)
}
}
view raw Config.kt hosted with ❤ by GitHub

2. 設置 Retrofit

Retrofit.Builder()
.baseUrl(BuildConfig.API_ROOT)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.build()
view raw Retrofit.kt hosted with ❤ by GitHub

這邊 CallAdapter 的用途是將 Retrofit 每個方法的回傳值 Call 轉換成另外的型態,我們是用 RxJava 當作回傳型態,所以增加了 RxJava2CallAdapterFactory.create()

Converter 的用途是讓 Retrofit API 回傳的 HTTP 字串可以轉換成我們自己定義的資料型態 (反序列化 Deserialize)以及轉換回去(序列化 Serialize),我們是使用 Gson,所以增加了 GsonConverterFactory.create()。不知道什麼叫「序列化」的朋友們這邊幫你做個快速的解釋,更詳盡的解釋可以自己搜尋:

  • Serialization 序列化:data object (kotlin 程式端使用的物件) -> json string (底層傳遞)
  • Deserialization 反序列化:json string -> data object

API 介面

API Service Interface

接下來就要來定義我們的 API 介面,我們這邊只會用到兩支 API:列表 /movie/{list} 和詳細頁 /movie/{movieId}

interface MovieApiService {
@GET("movie/{list}")
fun fetchMovieList(
@Path("list") list: String,
@Query("page") page: Int? = null
): Single<TmdbApiResponse<MovieListResponse>>
@GET("movie/{movieId}")
fun fetchMovieDetail(@Path("movieId") movieId: String): Single<MovieDetailResponse>
}
view raw MovieApiService.kt hosted with ❤ by GitHub

Retrofit 可以讓每個 Endpoint 都變成一個介面的方法,透過 Annotation (@GET, @Path, @Query…等等)來定義 API 的用法和參數,詳細使用方式可以參考文件,我們不多做額外的說明。

這邊特別要講的是每個 API 方法的回傳值,在 RxJava (2 以上) 裡面 Observable 主要有三種型態:Observable / Single / Maybe,而這兩個 API 只有兩種結果:成功回傳資料或者失敗,所以我們都選用 Single 當作回傳值。

Data Class for Response

TMDB API 回傳的格式是 JSON,而我們會利用 GSON 將 JSON 轉換成我們程式內的資料物件:

data class MovieListResponse(
@SerializedName("id")
val id: String,
@SerializedName("poster_path")
val posterPath: String?,
@SerializedName("title")
val title: String?,
...
)
view raw MovieListResponse.kt hosted with ❤ by GitHub

我們用 Data Class 當作 API 回傳的資料型態,API Response 可以參考上面兩個 API 的連結。這邊比較重要的是在於除了 id 之外,其他所有欄位皆為 Nullable,這樣做在使用這些欄位時可以強制讓你考慮到沒有該欄位值的情況,因為可能 API 發生錯誤而沒有這個欄位值,或者哪天 API 拿掉這個欄位值,你的 App 都假設欄位是可空,且都有處理空值,那麼你就不用擔心發生 NullPointerExceptioin (NPE) 而閃退。

另外,Gson 預設是可以用 Property 的名稱來和 API 欄位對上,不過考量到 API 欄位的命名規範可能和 Kotlin 建議的命名規範不同(API 使用底線分隔,Kotlin 使用駝峰式命名),所以我們還是使用 Gson 提供的 @SerializedName("...") 來指定 API 欄位名稱。

Http Client setup + Interceptor

Retrofit 底層也是使用 OkHttp 去完成工作,所以我們也可以設定一下 Retrofit 使用我們自己的 OkHttp 實體,而且官方文件提到比較好的作法是讓整個 App 都使用單一實體(考量到效能、連線數、延遲…等因素)。

val builder = OkHttpClient.Builder()
builder.protocols(listOf(Protocol.HTTP_1_1, Protocol.HTTP_2))
// TODO: set timeout, cache...etc.
val httpClient = builder.build()
Retrofit.Builder()
…(略)
.client(httpClient) // 加到 Retrofit
.build()
view raw OkHttpClient.kt hosted with ❤ by GitHub

Interceptor

TMDB 要求每一個 API Request 都要帶 API Key 在 Query String (/movie/popular?api_key=xxx"),Retrofit 是有提供 @Query 讓我們可以當作方法的參數傳入:

interface MovieApiService {
@GET("movie/{list}")
fun fetchMovieList(
...
@Query("api_key") apiKey: String
): Single<TmdbApiResponse<MovieListResponse>>
@GET("movie/{movieId}")
fun fetchMovieDetail(
...
@Query("api_key") apiKey: String
): Single<MovieDetailResponse>
}
view raw MovieApiService.kt hosted with ❤ by GitHub

但是這樣就會變成每一個 API 方法都要增加這個參數,之後 API 數量變多,這樣做法肯定不是一個很好的設計,那該怎麼辦呢?

OkHttp 的 Interceptor 可以來解決這樣的需求,它可以攔截所有送出的 Request / Response,我們就可以用它來幫我們的 Request / Response 做加工,像官方就有提供 HttpLoggingInterceptor 可以印出我們送出的所有 API 的詳細內容。那我們自己寫一個 Interceptor 來幫我們所有 API Request 都加上 API Key:

class ApiInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
val url = request.url.newBuilder().addQueryParameter("api_key", BuildConfig.TMDB_API_KEY).build()
request = request.newBuilder().url(url).build()
return chain.proceed(request)
}
}
view raw ApiInterceptor.kt hosted with ❤ by GitHub

完成之後,我們就可以把 ApiInterceptor 加到 OkHttp client 裡面了,BuildConfig.TMDB_API_KEY 已經在上面講解設定 Base URL 的時候加入了,在這邊就會用上,再來就把 Interceptor 加入到 OkHttp 的初始化當中。

val builder = OkHttpClient.Builder()
builder.addInterceptor(get<HttpLoggingInterceptor>())
builder.addInterceptor(get<ApiInterceptor>())
builder.protocols(listOf(Protocol.HTTP_1_1, Protocol.HTTP_2))
var httpClient = builder.build()
Retrofit.Builder()
…(略)
.client(httpClient) // 加到 Retrofit
.build()
view raw OkHttpClient.kt hosted with ❤ by GitHub

大功告成!我們資料來源章節就講到這,我們用到了 Retrofit + OkHttp + Gson 三個主要套件,更詳盡的用法可以參考各自的官方文件。 下一個章節會講解 Repo + Paging 的概念,會使用這篇的資料來源和 API,所以務必熟悉上面講解的內容。

如果你有任何和此專案相關的疑問,歡迎留言給我交流或討論。完整程式碼:https://github.com/enginebai/MovieHunt 歡迎 Fork + Star ⭐ 支持。

12 thoughts on “Android App 開發實戰系列 Part 2. 資料來源 API

Add yours

    1. 我們蠻多情況會用 ID 去做查詢,像是 `userId` 去查使用者資料、`orderId` 去查訂單資料,當這些 ID 沒有時,再接下去的步驟或者頁面根本無法正常運作或顯示,這是一個不正常的流程,「沒有了這資料就無法正常運作」。

      Like

  1. 您好,問一個新手問題,文中有提到要把 ApiKey.kt 檔案加入 .gitignore,我不知道要怎麼進行,也找不到 .gitignore 檔案,謝謝。

    Like

    1. .gitignore 是一個檔案,用來列舉哪些檔案或者資料夾要被 git 忽略追蹤,我記得我的專案有加,如果沒有這個檔案,可以在專案根目錄新增一個空白檔案,檔名為 .gitignore,然後就可以把 ApiKey.kt 相對路徑加入。

      Like

  2. 如果沒有 staging / production 環境,那麼 API Root 寫在 BuildConfig 意義是不是就不大了?

    Like

    1. 不會耶,有一件事在文中沒有特別提到(之後比較相關的章節才會提到),就是有些組態設定的東西寫在 BuildConfig,可以讓一些 production 敏感的資料(例如 API Key,Sign Config … 等)可以分離,App 專案在開發的時候就 “只” 提供 developer / staging 的組態,然後讓 production 只有有權限的人才能拿到,或者放在 CI/CD 上面,提升安全性。

      Like

  3. My spouse and i got more than happy Ervin managed to round up his studies via the ideas he received while using the web pages. It is now and again perplexing to simply choose to be handing out tricks which usually some other people might have been trying to sell. Therefore we see we’ve got you to give thanks to for this. The explanations you made, the simple site menu, the relationships you aid to promote – it is mostly awesome, and it’s letting our son in addition to us know that the idea is cool, and that’s rather essential. Thanks for everything!

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑