深入淺出 Retrofit,這麼牛逼的框架你們還不來看看?

文章來源:騰訊Bugly

Android 開發中,從原生的 HttpUrlConnection 到經典的 Apache 的 HttpClient,再到對前面這些網絡基礎框架的封裝,比如 VolleyAsync Http Client,Http 相關開源框架的選擇還是很多的,其中由著名的 Square 公司開源的 Retrofit 更是以其簡易的接口配置、強大的擴展支持、優雅的代碼結構受到大家的追捧。也正是由於 Square 家的框架一如既往的簡潔優雅,所以我一直在想,Square 公司是不是隻招處女座的程序員?

1、初識 Retrofit

單從 Retrofit 這個單詞,你似乎看不出它究竟是幹嘛的,當然,我也看不出來 :)逃。。

Retrofitting refers to the addition of new technology or features to older systems.

—From Wikipedia

於是我們就明白了,冠以 Retrofit 這個名字的這個傢伙,應該是某某某的 『Plus』 版本了。

1.1 Retrofit 概覽

Retrofit 是一個 RESTful 的 HTTP 網絡請求框架的封裝。注意這裏並沒有說它是網絡請求框架,主要原因在於網絡請求的工作並不是 Retrofit 來完成的。Retrofit 2.0 開始內置 OkHttp,前者專注於接口的封裝,後者專注於網絡請求的高效,二者分工協作,宛如古人的『你耕地來我織布』,小日子別提多幸福了。


我們的應用程序通過 Retrofit 請求網絡,實際上是使用 Retrofit 接口層封裝請求參數、Header、Url 等信息,之後由 OkHttp 完成後續的請求操作,在服務端返回數據之後,OkHttp 將原始的結果交給 Retrofit,後者根據用戶的需求對結果進行解析的過程。

講到這裏,你就會發現所謂 Retrofit,其實就是 Retrofitting OkHttp 了。

1.2 Hello Retrofit

多說無益,不要來段代碼陶醉一下。使用 Retrofit 非常簡單,首先你需要在你的 build.gradle 中添加依賴:

compile 'com.squareup.retrofit2:retrofit:2.0.2'

你一定是想要訪問 GitHub 的 api 對吧,那麼我們就定義一個接口:

public interface GitHubService {  
 @GET("users/{user}/repos")  Call> listRepos(@Path("user") String user); }

接口當中的 listRepos 方法,就是我們想要訪問的 api 了:

https://api.github.com/users/{user}/repos

其中,在發起請求時, {user} 會被替換爲方法的第一個參數 user

好,現在接口有了,我們要構造 Retrofit 了:

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .build();

GitHubService service = retrofit.create(GitHubService.class);

這裏的 service 就好比我們的快遞哥,還是往返的那種哈~

Call> repos = service.listRepos("octocat");

發請求的代碼就像前面這一句,返回的 repos 其實並不是真正的數據結果,它更像一條指令,你可以在合適的時機去執行它:


啥感覺?有沒有突然覺得請求接口就好像訪問自家的方法一樣簡單?吶,前面我們看到的,就是 Retrofit 官方的 demo 了。你以爲這就夠了?噗~怎麼可能。。

1.3 Url 配置

Retrofit 支持的協議包括 GET/POST/PUT/DELETE/HEAD/PATCH,當然你也可以直接用 HTTP 來自定義請求。這些協議均以註解的形式進行配置,比如我們已經見過 GET 的用法:

  @GET("users/{user}/repos")
  Call> listRepos(@Path("user") String user);

這些註解都有一個參數 value,用來配置其路徑,比如示例中的 users/{user}/repos,我們還注意到在構造 Retrofit 之時我們還傳入了一個 baseUrl("https://api.github.com/"),請求的完整 Url 就是通過 baseUrl 與註解的 value(下面稱 “path“ ) 整合起來的,具體整合的規則如下:

  • path 是絕對路徑的形式:
    path = "/apath"baseUrl = "http://host:port/a/b"
     Url = "http://host:port/apath"

  • path 是相對路徑,baseUrl 是目錄形式:
     path = "apath"baseUrl = "http://host:port/a/b/"
     Url = "http://host:port/a/b/apath"

  • path 是相對路徑,baseUrl 是文件形式:
     path = "apath"baseUrl = "http://host:port/a/b"
     Url = "http://host:port/a/apath"

  • path 是完整的 Url:
     path = "http://host:port/aa/apath"baseUrl = "http://host:port/a/b"
     Url = "http://host:port/aa/apath"
    建議採用第二種方式來配置,並儘量使用同一種路徑形式。如果你在代碼裏面混合採用了多種配置形式,恰好趕上你哪天頭暈眼花,信不信分分鐘寫一堆 bug 啊哈哈。

1.4 參數類型

發請求時,需要傳入參數,Retrofit 通過註解的形式令 Http 請求的參數變得更加直接,而且類型安全。

1.4.1 Query & QueryMap

@GET("/list")
Call list(@Query("page") int page);

Query 其實就是 Url 中 ‘?’ 後面的 key-value,比如:

http://www.println.net/?cate=android

這裏的 cate=android 就是一個 Query,而我們在配置它的時候只需要在接口方法中增加一個參數,即可:

interface PrintlnServer{    
  @GET("/")    
  Call cate(@Query("cate") String cate); }

這時候你肯定想,如果我有很多個 Query,這麼一個個寫豈不是很累?而且根據不同的情況,有些字段可能不傳,這與方法的參數要求顯然也不相符。於是,打羣架版本的 QueryMap 橫空出世了,使用方法很簡單,我就不多說了。

1.4.2 Field & FieldMap

其實我們用 POST 的場景相對較多,絕大多數的服務端接口都需要做加密、鑑權和校驗,GET 顯然不能很好的滿足這個需求。使用 POST 提交表單的場景就更是剛需了,怎麼提呢?

   @FormUrlEncoded
   @POST("/")   
  Call example(       @Field("name") String name,       @Field("occupation") String occupation);

其實也很簡單,我們只需要定義上面的接口就可以了,我們用 Field 聲明瞭表單的項,這樣提交表單就跟普通的函數調用一樣簡單直接了。

等等,你說你的表單項不確定個數?還是說有很多項你懶得寫?Field 同樣有個打羣架的版本——FieldMap,趕緊試試吧~~

1.4.3 Part & PartMap

這個是用來上傳文件的。話說當年用 HttpClient 上傳個文件老費勁了,一會兒編碼不對,一會兒參數錯誤(也怪那時段位太低吧TT)。。。可是現在不同了,自從有了 Retrofit,媽媽再也不用擔心文件上傳費勁了~~~

public interface FileUploadService {  
    @Multipart
    @POST("upload")    
Call upload(@Part("description") RequestBody description,                              @Part MultipartBody.Part file); }

如果你需要上傳文件,和我們前面的做法類似,定義一個接口方法,需要注意的是,這個方法不再有 @FormUrlEncoded 這個註解,而換成了 @Multipart,後面只需要在參數中增加 Part 就可以了。也許你會問,這裏的 Part Field 究竟有什麼區別,其實從功能上講,無非就是客戶端向服務端發起請求攜帶參數的方式不同,並且前者可以攜帶的參數類型更加豐富,包括數據流。也正是因爲這一點,我們可以通過這種方式來上傳文件,下面我們就給出這個接口的使用方法:


在實驗時,我上傳了一個只包含一行文字的文件:

Visit me: http://www.println.net

那麼我們去服務端看下我們的請求是什麼樣的:

HEADERS


FORM/POST PARAMETERS

description: This is a description

RAW BODY


我們看到,我們上傳的文件的內容出現在請求當中了。如果你需要上傳多個文件,就聲明多個 Part 參數,或者試試 PartMap

1.5 Converter,讓你的入參和返回類型豐富起來

1.5.1 RequestBodyConverter

1.4.3 當中,我爲大家展示瞭如何用 Retrofit 上傳文件,這個上傳的過程其實。。還是有那麼點兒不夠簡練,我們只是要提供一個文件用於上傳,可我們前後構造了三個對象:


天哪,肯定是哪裏出了問題。實際上,Retrofit 允許我們自己定義入參和返回的類型,不過,如果這些類型比較特別,我們還需要準備相應的 Converter,也正是因爲 Converter 的存在, Retrofit 在入參和返回類型上表現得非常靈活。

下面我們把剛纔的 Service 代碼稍作修改:

public interface FileUploadService {  
    @Multipart
    @POST("upload")    
   Call upload(@Part("description") RequestBody description,        
       //注意這裏的參數 "aFile" 之前是在創建 MultipartBody.Part 的時候傳入的        @Part("aFile") File file)
; }

現在我們把入參類型改成了我們熟悉的 File,如果你就這麼拿去發請求,服務端收到的結果會讓你哭了的。。。

RAW BODY


服務端收到了一個文件的路徑,它肯定會覺得


好了,不鬧了,這明顯是 Retrofit 在發現自己收到的實際入參是個 File 時,不知道該怎麼辦,情急之下給 toString了,而且還是個 JsonString(後來查證原來是使用了 GsonRequestBodyConverter。。)。

接下來我們就自己實現一個 FileRequestBodyConverter

  static class FileRequestBodyConverterFactory extends Converter.Factory {    
   @Override    public Converter requestBodyConverter(Type type, Annotation[] parameterAnnotations, Annotation[] methodAnnotations, Retrofit retrofit) {      
      return new FileRequestBodyConverter();    }  }  
     
static class FileRequestBodyConverter implements Converter<File, RequestBody> {    
   @Override    public RequestBody convert(File file) throws IOException {      
     return RequestBody.create(MediaType.parse("application/otcet-stream"), file);    }  }

在創建 Retrofit 的時候記得配置上它:

addConverterFactory(new FileRequestBodyConverterFactory())

這樣,我們的文件內容就能上傳了。來,看下結果吧:

RAW BODY


文件內容成功上傳了,當然其中還存在一些問題,這個目前直接使用 Retrofit 的 Converter 還做不到,原因主要在於我們沒有辦法通過 Converter 直接將 File 轉換爲 MultiPartBody.Part,如果想要做到這一點,我們可以對 Retrofit 的源碼稍作修改,這個我們後面再談。

1.5.2 ResponseBodyConverter

前面我們爲大家簡單示例瞭如何自定義 RequestBodyConverter,對應的,Retrofit 也支持自定義 ResponseBodyConverter

我們再來看下我們定義的接口:

public interface GitHubService {  
  @GET("users/{user}/repos")  Call> listRepos(@Path("user") String user); }

返回值的類型爲 List,而我們直接拿到的原始返回肯定就是字符串(或者字節流),那麼這個返回值類型是怎麼來的呢?首先說明的一點是,GitHub 的這個 api 返回的是 Json 字符串,也就是說,我們需要使用 Json 反序列化得到 List,這其中用到的其實是 GsonResponseBodyConverter

問題來了,如果請求得到的 Json 字符串與返回值類型不對應,比如:

接口返回的 Json 字符串:

{"err":0, "content":"This is a content.", "message":"OK"}

返回值類型

class Result{
    int code;//等價於 err
    String body;//等價於 content
    String msg;//等價於 message
}

哇,這時候肯定有人想說,你是不是腦殘,偏偏跟服務端對着幹?哈哈,我只是示例嘛,而且在生產環境中,你敢保證這種情況不會發生??

這種情況下, Gson 就是再牛逼,也只能默默無語倆眼淚了,它哪兒知道字段的映射關係怎麼這麼任性啊。好,現在讓我們自定義一個 Converter 來解決這個問題吧!


當然,別忘了在構造 Retrofit 的時候添加這個 Converter,這樣我們就能夠愉快的讓接口返回 Result 對象了。

注意!!Retrofit 在選擇合適的 Converter 時,主要依賴於需要轉換的對象類型,在添加 Converter 時,注意 Converter 支持的類型的包含關係以及其順序。

2、Retrofit 原理剖析

前一個小節我們把 Retrofit 的基本用法和概念介紹了一下,如果你的目標是學會如何使用它,那麼下面的內容你可以不用看了。


不過呢,我就知道你不是那種淺嘗輒止的人!這一節我們主要把注意力放在 Retrofit 背後的魔法上面~~


2.1 是誰實際上完成了接口請求的處理?

前面講了這麼久,我們始終只看到了我們自己定義的接口,比如:

public interface GitHubService {  
 @GET("users/{user}/repos")  Call> listRepos(@Path("user") String user); }

而真正我使用的時候肯定不能是接口啊,這個神祕的傢伙究竟是誰?其實它是 Retrofit 創建的一個代理對象了,這裏涉及點兒 Java 的動態代理的知識,直接來看代碼:


簡單的說,在我們調用 GitHubService.listRepos 時,實際上調用的是這裏的 InvocationHandler.invoke 方法~~

2.2 來一發完整的請求處理流程

前面我們已經看到 Retrofit 爲我們構造了一個 OkHttpCall ,實際上每一個 OkHttpCall 都對應於一個請求,它主要完成最基礎的網絡請求,而我們在接口的返回中看到的 Call 默認情況下就是 OkHttpCall 了,如果我們添加了自定義的 callAdapter,那麼它就會將 OkHttp 適配成我們需要的返回值,並返回給我們。

先來看下 Call 的接口:

public interface Call<T> extends Cloneable {  
 //同步發起請求  Response execute() throws IOException;  
 //異步發起請求,結果通過回調返回  void enqueue(Callback callback);  
 boolean isExecuted();  
 void cancel();  
 boolean isCanceled();  
 Call clone();  
 //返回原始請求  Request request(); }

我們在使用接口時,大家肯定還記得這一句:

Call> repos = service.listRepos("octocat");
List data = repos.execute();

這個 repos 其實就是一個 OkHttpCall 實例,execute 就是要發起網絡請求。

OkHttpCall.execute


我們看到 OkHttpCall 其實也是封裝了 okhttp3.Call,在這個方法中,我們通過 okhttp3.Call 發起了進攻,額,發起了請求。有關 OkHttp 的內容,我在這裏就不再展開了。

parseResponse 主要完成了由 okhttp3.Response retrofit.Response 的轉換,同時也處理了對原始返回的解析:

  Response parseResponse(okhttp3.Response rawResponse) throws IOException {
    ResponseBody rawBody = rawResponse.body();    
 
   //略掉一些代碼    try {      
   //在這裏完成了原始 Response 的解析,T 就是我們想要的結果,比如 GitHubService.listRepos 的 List      T body = serviceMethod.toResponse(catchingBody);      
     return Response.success(body, rawResponse);    } catch (RuntimeException e) {      
     // If the underlying source threw an exception, propagate that rather than indicating it was      // a runtime exception.      catchingBody.throwIfCaught();      
     throw e;    }  }

至此,我們就拿到了我們想要的數據~~

2.3 結果適配,你是不是想用 RxJava?

前面我們已經提到過 CallAdapter 的事兒,默認情況下,它並不會對 OkHttpCall 實例做任何處理:

final class DefaultCallAdapterFactory extends CallAdapter.Factory {  
 static final CallAdapter.Factory INSTANCE = new DefaultCallAdapterFactory();  

 @Override  public CallAdapter get(Type returnType, Annotation[] annotations, Retrofit retrofit) {    ... 毫不留情的省略一些代碼 ...    
   return new CallAdapter>() {      ... 省略一些代碼 ...      

    @Override public Call adapt(Call call) {        
    //看這裏,直接把傳入的 call 返回了        return call;      }    };  } }

現在的需求是,我想要接入 RxJava,讓接口的返回結果改爲 Observable

  public interface GitHub {    
   @GET("/repos/{owner}/{repo}/contributors")    Observable> contributors(        
      @Path("owner") String owner,        
      @Path("repo") String repo);  }

可不可以呢?當然是可以的,只需要提供一個 Adapter,將 OkHttpCall 轉換爲 Observable 即可呀!Retrofit 的開發者們早就想到了這個問題,並且爲我們提供了相應的 Adapter:

RxJavaCallAdapterFactory

我們只需要在構造 Retrofit 時,添加它:

addCallAdapterFactory(RxJavaCallAdapterFactory.create())

這樣我們的接口就可以以 RxJava 的方式工作了。

好,歇會兒,抽一袋煙。。。

接着我們搞清楚 RxJavaCallAdapterFactory 是怎麼工作的,首先讓我們來看下 CallAdapter 的接口:


代碼中做了較爲詳細的註釋,簡單來說,我們只需要實現 CallAdapter 類來提供具體的適配邏輯,並實現相應的 Factory,用來將當前的 CallAdapter註冊到 Retrofit 當中,並在 Factory.get 方法中根據類型來返回當前的 CallAdapter即可。知道了這些,我們再來看 RxJavaCallAdapterFactory


RxJavaCallAdapterFactory 提供了不止一種 Adapter,但原理大同小異,有興趣的讀者可以自行參閱其源碼。

至此,我們已經對 CallAdapter 的機制有了一個清晰的認識了。

3、幾個進階玩法

前面我們已經介紹了很多東西了。。可,挖掘機專業的同學們,你們覺得這就夠了麼?當然是不夠!

3.1 繼續簡化文件上傳的接口

在 1.5.1 當中我們曾試圖簡化文件上傳接口的使用,儘管我們已經給出了相應的 File -> RequestBody Converter,不過基於 Retrofit本身的限制,我們還是不能像直接構造 MultiPartBody.Part 那樣來獲得更多的靈活性。這時候該怎麼辦?當然是 Hack~~

首先明確我們的需求:

  • 文件的 Content-Type 需要更多的靈活性,不應該寫死在 Converter 當中,可以的話,最好可以根據文件的擴展名來映射出來對應的 Content-Type, 比如 image.png -> image/png;

  • 在請求的數據中,能夠正常攜帶 filename 這個字段。

爲此,我增加了一套完整的參數解析方案:

1. 增加任意類型轉換的 Converter,這一步主要是滿足後續我們直接將入參類型轉換爲 MultiPartBody.Part 類型:

 public interface Converter<F, T> {
   ...   

 abstract class Factory {     ...    
   //返回一個滿足條件的不限制類型的 Converter     public Converter arbitraryConverter(Type originalType,           Type convertedType, Annotation[] parameterAnnotations, Annotation[] methodAnnotations, Retrofit retrofit){      
      return null;     }   } }

需要注意的是,Retrofit 類當中也需要增加相應的方法:

   public  Converter arbitraryConverter(
          Type orignalType,Type convertedType,
          Annotation[] parameterAnnotations,
          Annotation[] methodAnnotations) {    
  return nextArbitraryConverter(null, orignalType, convertedType, parameterAnnotations, methodAnnotations);   }  
 
  public Converter nextArbitraryConverter(Converter.Factory skipPast,                                 Type type, Type convertedType,  Annotation[] parameterAnnotations, Annotation[] methodAnnotations) {     checkNotNull(type, "type == null");     checkNotNull(parameterAnnotations, "parameterAnnotations == null");     checkNotNull(methodAnnotations, "methodAnnotations == null");    
 
    int start = converterFactories.indexOf(skipPast) + 1;    
    for (int i = start, count = converterFactories.size(); i < count; i++) {       Converter.Factory factory = converterFactories.get(i);       Converter converter =               factory.arbitraryConverter(type, convertedType, parameterAnnotations, methodAnnotations, this);      
      if (converter != null) {        
      //noinspection unchecked         return (Converter) converter;       }     }    
    return null;   }

2. 再給出 arbitraryConverter 的具體實現:




3. 在聲明接口時,@Part 不要傳入參數,這樣 Retrofit ServiceMethod.Builder.parseParameterAnnotation 方法中解析 Part時,就會認爲我們傳入的參數爲 MultiPartBody.Part 類型(實際上我們將在後面自己轉換)。那麼解析的時候,我們拿到前面定義好的 Converter,構造一個 ParameterHandler

 ...
 } else if (MultipartBody.Part.class.isAssignableFrom(rawParameterType)) {     
    return ParameterHandler.RawPart.INSTANCE; } else {     Converter converter =             retrofit.arbitraryConverter(type, MultipartBody.Part.class, annotations, methodAnnotations);    
    if(converter == null) {        
        throw parameterError(p,                
              "@Part annotation must supply a name or use MultipartBody.Part parameter type.")
;     }     return new ParameterHandler.TypedFileHandler((Converter) converter); } ...
static final class TypedFileHandler extends ParameterHandler{

     private final Converter converter;

     TypedFileHandler(Converter converter) {
       this.converter = converter;
     }

     @Override
     void apply(RequestBuilder builder, TypedFile value) throws IOException {
       if(value != null){
         builder.addPart(converter.convert(value));
       }
     }
   }

4. 這時候再看我們的接口聲明:

   public interface FileUploadService {     
    @Multipart     @POST("upload")    
    Call upload(@Part("description") RequestBody description,                               @Part TypedFile typedFile);   }

以及使用方法:

 Retrofit retrofit = new Retrofit.Builder()
     .baseUrl("http://www.println.net/")
     .addConverterFactory(new TypedFileMultiPartBodyConverterFactory())
     .addConverterFactory(GsonConverterFactory.create())
     .build();

 FileUploadService service = retrofit.create(FileUploadService.class);
 TypedFile typedFile = new TypedFile("aFile", filename);
 String descriptionString = "This is a description";
 RequestBody description =
         RequestBody.create(
                 MediaType.parse("multipart/form-data"), descriptionString);

 Call call = service.upload(description, typedFile);
 call.enqueue(...);

至此,我們已經通過自己的雙手,讓 Retrofit 的點亮了自定義上傳文件的技能,風騷等級更上一層樓!

3.1.2 Mock Server

我們在開發過程中,經常遇到服務端不穩定的情況,測試開發環境,這是難免的。於是我們需要能夠模擬網絡請求來調試我們的客戶端邏輯,Retrofit 自然是支持這個功能的。

真是太貼心,Retrofit 提供了一個 MockServer 的功能,可以在幾乎不改動客戶端原有代碼的前提下,實現接口數據返回的自定義,我們在自己的工程中增加下面的依賴:

compile 'com.squareup.retrofit2:retrofit-mock:2.0.2

還是先讓我們來看看官方 demo,首先定義了一個 GituHb api,好熟悉的感覺:

  public interface GitHub {    
 @GET("/repos/{owner}/{repo}/contributors")    Call> contributors(        
      @Path("owner") String owner,        
      @Path("repo") String repo);  }

這就是我們要請求的接口了,怎麼 Mock 呢?

1. 定義一個接口實現類 MockGitHub,我們可以看到,所有我們需要請求的接口都在這裏得到了實現,也就是說,我們待會兒調用 GitHub 的 api 時,實際上是訪問 MockGitHub 的方法:


2. 構建 Mock Server 對象:

 // Create a very simple Retrofit adapter which points the GitHub API.
 Retrofit retrofit = new Retrofit.Builder()
     .baseUrl(SimpleService.API_URL)
     .build(); 

// Create a MockRetrofit object with a NetworkBehavior which manages the fake behavior of calls. NetworkBehavior behavior = NetworkBehavior.create(); MockRetrofit mockRetrofit = new MockRetrofit.Builder(retrofit)     .networkBehavior(behavior)     .build(); BehaviorDelegate delegate = mockRetrofit.create(GitHub.class); MockGitHub gitHub = new MockGitHub(delegate);

3. 使用 Mock Server

 Call> contributors = gitHub.contributors(owner, repo);
 ...

也就是說,我們完全可以自己造一個假的數據源,通過 Mock Server 來返回這些寫數據。

那麼問題來了,這其實並沒有完全模擬網絡請求的解析流程,如果我只能提供原始的 json 字符串,怎麼通過 Retrofit 來實現 Mock Server

時間已經不早啦,我就不猥瑣發育了,直接推塔~

本文前面一直專注於介紹 Retrofit,很少提及 OkHttp,殊不知 OkHttp 有一套攔截器的機制,也就是說,我們可以任性的檢查 Retrofit 即將發出或者正在發出的所有請求,並且篡改它。所以我們只需要找到我們想要的接口,定製自己的返回結果就好了,下面是一段示例:


這樣,我們就會攔截 contributors 這個 api 並定製其返回了。

4、小結

Retrofit 是非常強大的,本文通過豐富的示例和對源碼的挖掘,向大家展示了 Retrofit 自身強大的功能以及擴展性,就算它本身功能不能滿足你的需求,你也可以很容易的進行改造,畢竟人家的代碼真是寫的漂亮啊。

另外,我之前也寫過兩篇文章介紹我對 Retrofit 的 Hack,歡迎賞光~

  1. Android 下午茶:Hack Retrofit 之 增強參數(http://www.println.net/post/Android-Hack-Retrofit 請複製此鏈接到瀏覽器打開)

  2. Android 下午茶:Hack Retrofit (2) 之 Mock Serverhttp://www.println.net/post/Android-Hack-Retrofit-Mock-Server 打開方式同上

文中 Hack 之後的 Retrofit 代碼見 GitHubhttps://github.com/enbandari/HackRetrofit))。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章