Retrofit一直是一套在開發REST client一個相當好用的工具, 不只可以應用在Android上, 而是在任何Java相關的, 不管是獨立的App, 或是Server端的開發, 都相當好用

Retrofit是相當簡單容易使用的, 但它的能力也是強大的, 能做到的事情相當的多, 寫這篇主要是最近都在寫Spring Boot相關的, 在想說研究一下Spring的RestTemplateRetrofit時, 發現了Jake Wharton一份投影片, 之前一直以為Retrofit最早應該是由Jake Wharton開發出來的, 但後來根據Jake Wharton的文章才知道第一個commit是Square的CTO, Bob Lee, 但不管是誰, 這兩個都是大神級的人物啊

這篇主要是根據Jake WhartonMaking Retrofit Work For You (Ohio DevFest 2016)整理出來的, 這篇滿滿都是codes的投影片已經非常清楚的講了一堆關於Retrofit的技巧了, 這邊針對幾個有用的整理出來, 並加上一些我自己的內容(看完我文章後想繼續深入研究可以再回頭看投影片)

Retrofit的基本使用

在看這些技巧之前, 我們先來看看Retrofit的基本使用, 這邊使用官方的例子來說明, 官方的範例是以GitHub Api為例子, 以GitHub Api的“List user repository”為例, 它的URL是長這樣的:

GET /users/:username/repos

因此Retrofit的API interface (或稱Service class)可以定義成這樣:

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

從API doc裡面發現, 其實還可以用Query parameter加上"type", “sort”, “direction"三個參數, 如果我們希望再加上一個"sort"就可以變成這樣:

public interface GitHubService {
  @GET("users/{user}/repos")
  Call<List<Repo>> listRepos(@Path("user") String user, @Quer("sort") String sort);
}

這樣我們要取用GitHubService就可以用這樣:

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

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

基本上我們自己不用寫跟Http connection相關的程式碼就可以達成, 這就是Retrofit簡單的地方, 只需要定義一個Java interface, 跟幾個簡單的annotation即可

它的原理也不算很高深, 基本上就是利用了Java Proxy class, 把一些寫REST client共通的程式碼封裝起來, Proxy class雖然一般來說是比較冷門的技巧, 也不是什麼新功能, 但卻是相當好用的工具, 其實現在很多地方也都廣泛地使用了

這邊有幾篇關於Proxy class可以參考參考

另外, 當然得記得加入相關的程式庫

Maven:

<dependency>
  <groupId>com.squareup.retrofit2</groupId>
  <artifactId>retrofit</artifactId>
  <version>2.2.0</version>
</dependency>

Gradle:

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

json2pojo

有了Retrofit後, 在寫REST client最繁雜的部分大概就剩下為了JSON資料格式定義相對應的Java Class了, 不過, 這部分, 也是交給工具就好了, 這邊有兩個我常用的好用的工具:

  • jsonschema2pojo 這蠻多功能的, 也可以針對Gson或是Jackson產生不同的對應
  • Json2Pojo 如果使用Intellij IDEA或是他衍生出的Android Studio, 也可以使用這個plugin, 直接用"New -> Generate POJOs from JSON"即可

OkHttpClient與Interceptor

在現在處處都需要Internet的世界, 一個Http client的實作應該相當基本的, 除了Java本身的HttpUrlConnection外, 也有Apache Http, 不過Retrofit用的是Square自己開源的OkHttp,不得不說Square真是一家厲害的公司, 開源出來的東西的品質都相當的高, OkHttp也是一個相當優秀的Http程式庫, 大概跟Http相關的, 你想得到的都支援了, 包含HTTP/2

如果沒特別指定, Retrofit使用的會是預設的OkHttpClient, 當然你也可以指定自己的給它, 像是

OkHttpClient client = new OkHttpClient.Builder().build();

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

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

通常, 如果你對Http部分有特別的需求, 就會需要這樣做, 比如說, 你希望你每一個HTTP連接有它的connection timeout或是read timeout, write timeout, 那就會利用:

OkHttpClient client = new OkHttpClient.Builder()
		.connectTimeout(10, TimeUnit.SECONDS)
        .writeTimeout(10, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
		.build();

但這並不是唯一用途, 還有一個蠻好用的用途是Interceptor, 什麼是Interceptor呢? 用一張圖來解釋一下:

Interceptor

簡而言之, Interceptor是用來放在傳輸的中間去監測或修改HTTP的需求(request)與回應的(response), 在OkHttp裡, Interceptor有兩種, 一種是Application Interceptor, 是介於你的程式和OkHttp的實作之間, 另一種是Network Interceptor, 是介於OkHttp跟實際網路傳輸之間, 兩者實作的介面都相同, 實際上就要看你的用途放在哪了

一個最淺而易見的應用是Log, 常常我們會需要看HTTP傳輸了什麼來確定我們REST client的實作是否正確, 透過Interceptor就可以做到這樣的事, 而且OkHttp已經提供好一個叫做HttpLoggingInterceptor可以用了:

HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(Level.BASIC);
OkHttpClient client = new OkHttpClient.Builder()
  .addInterceptor(logging)
  .build();

利用addInterceptor可以加入Interceptor, 而且, 當然你也可以設定自己的Logger:

HttpLoggingInterceptor logging = new HttpLoggingInterceptor(new Logger() {
  @Override public void log(String message) {
    Timber.tag("OkHttp").d(message);
  }
});

不過HttpLoggingInterceptor並不包含在原本的OkHttp的jar內, 所以要另外加入:

Maven:

<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>logging-interceptor</artifactId>
  <version>(insert latest version)</version>
</dependency>

Gradle:

compile 'com.squareup.okhttp3:logging-interceptor:(insert latest version)'

除了做Log外, 另外就是如果有一些共用的Header, 像是Authentication的header, 就可以透過Interceptor來加入, 如:

class ServiceInterceptor implements Interceptor {
	@Override
	public Response intercept(Chain chain) {
		Request request = chain.request();

		request = request.newBuilder()
		    .addHeader("Authentication", "myheader")
			.build();
		return chain.process(request);
	}
}

這是用來把原本的request替換成加了header的新request, 同樣的, 如果是OAuth也是可以利用這技巧, 這部分可以去參考okhttp-signpost

那如果針對有些需要加, 有些不需要呢? 那我們可以用另一個技巧來處理, 假設, Retrofit的Service定義是這樣的:

interface Service {
	@GET("/user")
	Call<User> user();

	@POST("/login")
	@Headers("No-Auth: true")
	Call<User> login();
}

這範例裡面, user() 是需要加authentication header的, login不需要, 因此在這邊就利用Headers這個Authentication加上一個假header, 這是為了給後面的Interceptor辨識的,結果就像是:

class ServiceInerceptor implements Interceptor {
	@Override
	public Response intercept(Chain chain) {
		Reuqest request = chain.request();

		if (request.header("No-Auth") == null) {
			request = request.newBuilder()
		    .addHeader("Authentication", "myheader")
			.build();
		}

		return chain.proceed(request);
	}
}

Converter

剛剛講的Inerceptor嚴格說來不屬於Retrofit, 但接下來要說的這個Converter就完全是Retrofit的東西了

一般常見的REST API的回傳格式大多是JSON, 但也有人是用Protocol buffer, 也有人還是用著XML, Retrofit好用的地方就是沒寫死這部分的格式, 而是把它變成像是Plugin的形式, 就叫做Converter, 官方提供的Converter有下列這些(後面是maven repo的group:artifect):

  • Gson: com.squareup.retrofit2:converter-gson
  • Jackson: com.squareup.retrofit2:converter-jackson
  • Moshi: com.squareup.retrofit2:converter-moshi
  • Protobuf: com.squareup.retrofit2:converter-protobuf
  • Wire: com.squareup.retrofit2:converter-wire
  • Simple XML: com.squareup.retrofit2:converter-simplexml

其中Gson, Jackson, Moshi是處理JSON相關的, Protobuf和Wire則是處理Protocol buffer, 有趣的是, Square常常很多東西都喜歡自己來, 因此關於JSON parser, 他們就不滿足於Google的GSON或是常見的Jackson, 而是自己開發另一套Moshi, Protocol buffer也一樣, Wire也是他們自己開發的

如果要用Converter, 就會使用到它對應的CoverterFactory, 以GSON為例是:

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

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

由於Converter等於是一種plugin, 如果你不滿意於官方提供的幾個JSON方案, 而想要用其他的, 也是可以的, 像是如果你要用阿里巴巴開源的Fast JSON你也可以參考這個Converter的實作: FastJson Retrofit Converter

那Converter只能支援這些格式嗎? 你只要想得出來的格式其實都可以自己寫出Converter來支援, 比如說HTML也可以,

class PageConverter implements Converter<ResponseBody, Page> { 
    static final Converter.Factory FACTORY = new Converter.Factory() {
      @Override 
	  public Converter<ResponseBody, ?> responseBodyConverter( 
          Type type, Annotation[] annotations, Retrofit retrofit) { 
        if (type == Page.class) return new PageConverter(); 
        return null; 
      } 
    }; 
 
    @Override 
	public Page convert(ResponseBody responseBody) throws IOException {
      Document document = Jsoup.parse(responseBody.string());
      List<String> links = new ArrayList<>();
      for (Element element : document.select("a[href]")) {
        links.add(element.attr("href"));
      } 
      return new Page(document.title(), Collections.unmodifiableList(links));
    } 
  } 

上面這個Converter的例子, 就是一個把HTML裡的title和links(利用Jsoup)抓出來的範例, 也可以參考Retrofit提供的完整範例

除了這例子外還可以有更進階的玩法, 比如說這例子:

interface Service {
	@GET("/user")
	Call<User> user();

	@GET("/friends")
	Call<User> friends();
}

假設寫API的人真的很惡搞, /user傳回的是JSON, 而/friends傳回的是protobuf怎辦呢?(淦, 誰會這麼幹啦?)

這時候你可以用一個你自訂的annotation來處理, 像是這樣:

interface Service {
	@GET("/user")
	@Json
	Call<User> user();

	@GET("/friends")
	@Protobuf
	Call<User> friends();
}

這邊@Json, @Protobuf都是自定義的, 不是Retrofit提供的, 那我們在自己提供的ConverterFactory來處理, 像是:

public class AnnotatedConverterFactory extends Converter.Factory {
	final Converter.Factory gson = ... //init gson converter factory
	final Converter.Factory proto = ... //init protobuf factory

	@Override 
	  public Converter<ResponseBody, ?> responseBodyConverter( 
          Type type, Annotation[] annotations, Retrofit retrofit) { 
			  for(Annotation annotation:annotations) {
				  if(annotation.getClass() == Json.class) {
					  return gson.responseBodyConverter(type, annotations, retrofit);
				  } else {
					  return proto.responseBodyConverter(type, annotations, retrofit);
				  }
			  }
			  return null
		  }
}

更進階的用法可以參考Retrofit的Sample: AnnotatedConverters.java

Mock Mode

Retrofit也提供一個可以利用在測試的Mock mode

假設我們有一個Service interface是這樣的:

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

我們可以建立一個Mock Service像是:

/** A mock implementation of the {@link GitHub} API interface. */ 
public class MockGitHub implements GitHub { 
	private final BehaviorDelegate<GitHub> delegate;
	private final Map<String, Map<String, List<Contributor>>> ownerRepoContributors;

	MockGitHub(BehaviorDelegate<GitHub> delegate) {
		this.delegate = delegate;
		ownerRepoContributors = new LinkedHashMap<>();

		// Seed some mock data. 
		addContributor("square", "retrofit", "John Doe", 12); 
		addContributor("square", "retrofit", "Bob Smith", 2); 
		addContributor("square", "retrofit", "Big Bird", 40); 
		addContributor("square", "picasso", "Proposition Joe", 39); 
		addContributor("square", "picasso", "Keiser Soze", 152); 
	} 

	@Override public Call<List<Contributor>> contributors(String owner, String repo) {
		List<Contributor> response = Collections.emptyList();
		Map<String, List<Contributor>> repoContributors = ownerRepoContributors.get(owner);
		if (repoContributors != null) {
		List<Contributor> contributors = repoContributors.get(repo);
		if (contributors != null) {
			response = contributors;
		} 
		} 
		return delegate.returningResponse(response).contributors(owner, repo);
	} 

	void addContributor(String owner, String repo, String name, int contributions) {
		Map<String, List<Contributor>> repoContributors = ownerRepoContributors.get(owner);
		if (repoContributors == null) {
		repoContributors = new LinkedHashMap<>();
		ownerRepoContributors.put(owner, repoContributors);
		} 
		List<Contributor> contributors = repoContributors.get(repo);
		if (contributors == null) {
		contributors = new ArrayList<>();
		repoContributors.put(repo, contributors);
		} 
		contributors.add(new Contributor(name, contributions));
	} 
} 

這邊很簡單, 建立一個GitHub這個Interface的實作, 但資料不是透過HTTP去取得, 取而代之的是回傳我們預設好的假資料, 利用BehaviorDelegate<GitHub>來回傳, 這樣可以在測試中避免因為Server帶來的不確定性所造成的錯誤

建立實際上使用的Service實體就不是用原本的retrofit.create()了, 而是改用mockRetrofit.create(GitHub.class)像是這樣:

// Create a very simple Retrofit adapter which points the GitHub API. 
Retrofit retrofit = new Retrofit.Builder()
	.baseUrl(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<GitHub> delegate = mockRetrofit.create(GitHub.class);
MockGitHub gitHub = new MockGitHub(delegate);

這邊有一個networkBehavior, 這是可以用來模擬網路情況的, 比如說你可以用:

behavior.setDelay(500, TimeUnit.MILLISECONDS);
behavior.setFailurePercent(3);

這個可以用來測試可能的網路情況是否會帶來其他的邊際效應

補充 - CallAdapter

Retrofit 1時, Interface裡面定義的method回傳都是直接是要回傳的型態如:

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

在Retorfit 2之後, 卻已經變成是:

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

亦即就是, 原本在Retrofit 1採用的是Synchronous call, 就是你自己去管thread, 前景背景, 這些麻煩事, 但在2版後, 這部分就改了, 預設是Call這個Class, Call這個Interface的原型是這樣的

public interface Call<T> extends Cloneable {
  Response<T> execute() throws IOException;
  void enqueue(Callback<T> callback);
  boolean isExecuted(); 
  void cancel(); 
  boolean isCanceled(); 
  Call<T> clone(); 
  Request request(); 
} 

你拿到Call物件後, 其實並還沒透過HTTP去抓取東西, 而是要透過execute()或是enqueue()才會真的去發request, 這兩者本質上是不同的, execute()是一個Synchronous call, 也就是執行到有結果才會結束, 會卡程式執行, 而enqueue()則是Asynchronous call, HTTP的部份是在背景執行, 結束後會call callback

如果需要中斷(碰到下載很久的內容), 可以呼叫cancel()

對這部份內部實作有興趣的話可以參考OkHttpCall, 其實它是直接利用了OkHttp那邊的實作

當然, 誠如前面說的(有說過嗎?), Retrofit是一個高度模組化的套件, 因此這部分也可以透過所謂的Call Adapter換成你熟悉的進程管理(應該叫這樣嗎?不知道該怎稱呼), 如RxJava

例如:

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("http://example.com") 
        .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) 
        .build();

或是:

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("http://example.com") 
        .addCallAdapterFactory(RxJavaCallAdapterFactory.createWithScheduler(io())) 
        .build();

當然要記得加入相關的Dependency:

compile 'com.squareup.retrofit2:adapter-rxjava:latest.version'

官方支援的Call adapter除了原本的"Call"外還有:

另外非官方的, 比如說你如果喜歡用Facebook的Bolts, 也有retrofit-bolts-call-adapter

那如果我要寫自己的Call Adapter呢?討厭啦, 當然可以, 你想怎樣都可以啦! XD

補充2 - Interceptor

除了可以改造Request外, 這邊也可以傳自己假造的Response回去喔, 這種可以用在某些情境, 比如說沒網路狀況下, 你也想傳自己假造的預設資料

Retrofit目前已然成為最熱門的呼叫REST API的開源程式庫了,不過, 大部分的Retrofit多是用來處理Json類的REST API, 但Retrofit的能力卻不僅限於此

透過Converter, Retrofit可以處理的不只是Json, 還包含了XML, Protobuf, 甚至你自己的自訂格式, 剛好最近看Json不太順眼, 想來試試Protobuf, 就來試試這部分

Retrofit官方網頁上列的Protobuf converter有兩種, 一種是使用Google親生的Protobuf, 這個只要引入com.squareup.retrofit2:converter-protobuf即可使用, 另一種是有點比較吸引我的是Square自己開發出來的Wire, Sqaure真的是蠻喜歡打造自己的東西的, 打造出來的又比別人威, 這點讓我對Wire的興趣比較大, 因此這篇主要是以Wire當範例來介紹

使用Wire converter其實相當簡單的:

在build.gradle內加入相關的dependencies, 包含了wire runtime跟retorfit的converter:

compile 'com.squareup.retrofit2:converter-wire:2.1.0'
compile 'com.squareup.wire:wire-runtime:2.2.0'

在build service時, 用addConverterFactoryWireConverterFactory加入即可

Retrofit retrofit = new Retrofit.Builder()
		.baseUrl(baseUrl)
		.addConverterFactory(WireConverterFactory.create())
		.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
		.build();
DataService service = retrofit.create(DataService.class);

DataService的定義如下(這範例搭配了rxjava2):

public interface DataService {
    @GET("data")
    Single<Posts> getPosts();
}

這邊有個問題, Posts這個class並不是直接用Java刻出來的, 而是由.proto檔產生的, 內容如下:

syntax = "proto3";
package wtf.cowbay.dp;

message FBPost {
	string id = 1;
	string title = 2;
	string message = 3;
	string imgsrc = 4;
	string target = 5;
	string createdtime = 6;
}

message Posts {
	repeated FBPost data = 1;
}

必須要用工具產生Java class才能使用, Google的Protobuf有自己的工具, 而Wire也有自己的, 如果手動自己執行工具產生檔案後再加入, 未免太鳥,

還好Wire有wire-gradle-plugin, 不過這個有個大問題, 雖然放在Square的repo下, 但似乎 不是官方版本, 而是有人貢獻的, 因此並沒有跟上最新的2.x的版本, JakeWharton大神說將會有官方版本, 但似乎從六月到現在都沒出現, 所以只好自力救濟自己改 - 我的版本

使用這個plugin很簡單, 先在第一層的build.gradle裡的buildscript加入:

buildscript {
    repositories {
        jcenter()
        maven {
            url "https://jitpack.io"
        }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.2.3'

        classpath 'com.github.CowBayStudio:wire-gradle-plugin:ver12'
    }
}

主要是加入jitpack.io, 並把我的版本的plugin放到dependencies去

接下來在app的build.gradle裡加入:

apply plugin: 'com.squareup.wire'

.proto檔案放置的位子在app/src/main/proto, 把檔案放在這邊, build的時候就會自動產生這些對應的Java classes了

自從Android導入gradle之後, 使用開放的第三方的程式庫就越來越方便了, 雖然方便, 但也不免會碰到這類的問題:

  1. 想要的功能在master branch上更新了, 但卻遲遲不release以至於想用新的功能無法用
  2. 程式碼已經沒在維護了, maven repository上一直都還是有問題的舊版本, 明知道怎麼修卻無法代他release到maven repository上去, PR又遲遲沒人理
  3. 想加上自己的私有功能, 又不想包整包source codes到app裡面去
  4. 想要開放自己做的程式庫卻覺得release到maven很麻煩

還好有Jitpack這東西, 剛剛就是碰到一個東西有問題, 想把它修掉直接用, 研究了一下

用法很簡單, 首先要先把maven { url 'https://jitpack.io' }加入到repositories裡面去

allprojects {
    repositories {
        maven { url 'https://jitpack.io' }
    }
}

然後在你的dependencies裡面加入:

compile 'com.github.User:Repo:Tag'

User就是你Github的user name, Repo是Repository的名稱, Tag是Git的tag名稱, 舉個例:https://github.com/CowBayStudio/wire-gradle-plugin , 像這樣的Url, User name就是CowBayStudio, repo就是wire-gradle-plugin , 如果你的Git repo並沒有任何的Tag, 可以用Commit hash或是branch-SNAPSHOT(例如master-SNAPSHOT), 當然 自己加上tag會是比較好的做法, 比較好控管

當你去build你的app時, 在抓這個dependency時, Jitpack就會自動幫你把code從github抓下來build好, 當然是developer就免不了有bug, 導致build fail, 去 Jitpack網站把你Github的URL貼上去就可以找到build log了, 像是這個

我在弄我的東西時, 就發生了build fail的狀況, 而問題的原因是其中一個依賴的jar是JAVA 8 build出來的, 但jitpack用Java 7去build我的程式庫(文件說default應該是Java 8呀, 騙我!), 這時候可以加一個jitpack.yml, 內容如下:

jdk:
  - oraclejdk8

透過jitpack.yml可以有很多客制可設定, 詳情就看一下文件吧!

所以碰到自己想動的程式庫, 可以直接fork出來改, 改出來的就可以直接這樣用了

那private repo呢?付錢給Jitpack就有啦! XD

在使用很多API服務像是Google的API或是Facebook的API, 常常會需要拿簽署APK的Key的signature登錄, 取得這sigature的方法有很多種, 剛學到一種是直接可以從Android studio的Gardle選單內取得的, 方法如下:

Gradle->(Project name)->Tasks->singningReports

WebRTC是一個支援瀏覽器的即時影音對話的架構, 算是一個業界標(W3C,IETF), 最近由於想做一個有影音通話的應用, 就研究了一下這東西

如果只是想嘗試一下WebRTC, 是可以直接是可以直接試AppRTC這個Google的範例, 不過這個是Web的版本, 我想要做的是 手機的版本(Android, iOS), AppRTC其實也有Android的版本可搭配

為了熟悉一下整個用WebRTC建立video call的流程, 因此我就決定改一下這個Android版本, 原本Google的版本是透過Web Socket

至於流程與架構我會建議看這影片:

如果不想看太長, 就看這個:

把Web RTC那段換成Firebase(好, 其實我蠻後悔選Firebase來實作的)其實就是把Signaling這段給換掉, 而這段流程是(節錄自影片):

這部分其實就是交換兩邊的SDP和ICE candidate的過程, 詳細可以參考這邊:WebRTC 相關縮寫名詞簡介

結果的source code放在這邊 : apprtc-android-demo

Building WebRTC lib on Android

其實現在寫WebRTC的應用的話, 也不用從頭實作, Google老早就把它實作在Chromium裡面了, 也可以單獨build出library用

這邊有官方的如何建置出Android版本的Web RTC library, 不過, 不要照著這份文件做呀, 不然頭髮會白好幾根, 可能還build不太起來, 找了一堆網路上人家的建議也都是不要直接build, 直接用人家build好現成的, 不過, 現成的雖然有一些, 但大多是過時的, API跟現今的也不太一樣, 如果 要套用到現在的Android版本AppRTC的source code內, 大多都沒辦法用

所幸找到這個build script: pristineio/webrtc-build-scripts, 這個從下載最新的source code到build出library一律包辦, 用法也很簡單, 只要執行下面的:

source android/build.sh
install_dependencies
get_webrtc
build_apprtc

簡單明暸, 但…有幾個問題, 第一個是只能在Linux下build, 因此在Mac跟Windows下要透過Vagrant這類的工具, 而且對硬體需求也很高, 我的2012年中版的Macbook Pro retina實在是跑不動, 後來跑去Digital Ocean租了台VM來build, 本以為最便宜的可以勝任, 後來發現, 至少要4G RAM, 硬碟要20G以上的instance(哭哭, 浪費好多時間)

build出來後, 所需要的東西包含了libjingle_peerconnection.jar和libjingle_peerconnection_so.so, 把這幾個備份起來就是了, 待會build apk需要用

AppRTC 範例的Android source codes

Android的範例的source codes可以在這邊下載

不過這並不是Android studio的project格式, 因此需要用匯入的方式, 或是可以直接fork我的版本去改, 由於原本的版本使用了Web socket做singaling的管道, 因此需要Autobahn, 但你切記絕對不能用Autobahn官方最新的jar檔, 而是要用Google放在third_party裡面那個autobanh.jar(啊, 我到現在才發現名字有些許不同), 這邊的差異是, 原本Autobahn是沒有支援SSL的websocket的, 但AppRTC的websocket則是要透過SSL來連接

把jar跟so檔放到對應的目錄去後, 記得改一下app目錄下的build.gradle加入 (因為import產生的不會幫你加):

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
}

加上firebase

除了原本的Webcoket和Direct connect兩種方式外, 為了跑一次他的流程我多加了Firebase的部分, 利用它的realtime database來做Signaling這部分, 至於怎樣開始開發firebase, 就參考一下他的官方文件

Signaling的實作

CallActivity

選擇哪種signaling的方式是在CallActivity裡面依據roomId來看使用哪一個signaling client, 程式碼如下:

    // Create connection client. Use DirectRTCClient if room name is an IP otherwise use the
    // standard WebSocketRTCClient.
    if("firebase".equals(roomId)) {
      Log.d(TAG, "firebase");
      appRtcClient = new FirebaseRTCClient(this);
    } else if (loopback || !DirectRTCClient.IP_PATTERN.matcher(roomId).matches()) {
      appRtcClient = new WebSocketRTCClient(this);
    } else {
      Log.i(TAG, "Using DirectRTCClient because room name looks like an IP.");
      appRtcClient = new DirectRTCClient(this);
    }

原本有WebSocketRTCClient和DirectRTCClient, 如果是IP的話就用DirectRTCClient,這邊我多加一個FirebaseRTCClient, 只要roomId是firebase就會使用這個(我偷懶)

FirebaseRTCClient

XXXRTCClient這部分實作了signaling的部分, 因此我參考了WebSocketRTCClient和DirectRTCClient的內容來寫FirebaseRTCClient

跟WebSocketRTCClinet一樣, 它必須實作AppRTCClient, AppRTCClient這個Interface定義如下:

/**
 * AppRTCClient is the interface representing an AppRTC client.
 */
public interface AppRTCClient {
  /**
   * Struct holding the connection parameters of an AppRTC room.
   */
  class RoomConnectionParameters {
    public final String roomUrl;
    public final String roomId;
    public final boolean loopback;
    public RoomConnectionParameters(String roomUrl, String roomId, boolean loopback) {
      this.roomUrl = roomUrl;
      this.roomId = roomId;
      this.loopback = loopback;
    }
  }

  /**
   * Asynchronously connect to an AppRTC room URL using supplied connection
   * parameters. Once connection is established onConnectedToRoom()
   * callback with room parameters is invoked.
   */
  void connectToRoom(RoomConnectionParameters connectionParameters);

  /**
   * Send offer SDP to the other participant.
   */
  void sendOfferSdp(final SessionDescription sdp);

  /**
   * Send answer SDP to the other participant.
   */
  void sendAnswerSdp(final SessionDescription sdp);

  /**
   * Send Ice candidate to the other participant.
   */
  void sendLocalIceCandidate(final IceCandidate candidate);

  /**
   * Send removed ICE candidates to the other participant.
   */
  void sendLocalIceCandidateRemovals(final IceCandidate[] candidates);

  /**
   * Disconnect from room.
   */
  void disconnectFromRoom();

  /**
   * Struct holding the signaling parameters of an AppRTC room.
   */
  class SignalingParameters {
    public final List<PeerConnection.IceServer> iceServers;
    public final boolean initiator;
    public final String clientId;
    public final String wssUrl;
    public final String wssPostUrl;
    public final SessionDescription offerSdp;
    public final List<IceCandidate> iceCandidates;

    public SignalingParameters(List<PeerConnection.IceServer> iceServers, boolean initiator,
        String clientId, String wssUrl, String wssPostUrl, SessionDescription offerSdp,
        List<IceCandidate> iceCandidates) {
      this.iceServers = iceServers;
      this.initiator = initiator;
      this.clientId = clientId;
      this.wssUrl = wssUrl;
      this.wssPostUrl = wssPostUrl;
      this.offerSdp = offerSdp;
      this.iceCandidates = iceCandidates;
    }
  }

  /**
   * Callback interface for messages delivered on signaling channel.
   *
   * <p>Methods are guaranteed to be invoked on the UI thread of |activity|.
   */
  interface SignalingEvents {
    /**
     * Callback fired once the room's signaling parameters
     * SignalingParameters are extracted.
     */
    void onConnectedToRoom(final SignalingParameters params);

    /**
     * Callback fired once remote SDP is received.
     */
    void onRemoteDescription(final SessionDescription sdp);

    /**
     * Callback fired once remote Ice candidate is received.
     */
    void onRemoteIceCandidate(final IceCandidate candidate);

    /**
     * Callback fired once remote Ice candidate removals are received.
     */
    void onRemoteIceCandidatesRemoved(final IceCandidate[] candidates);

    /**
     * Callback fired once channel is closed.
     */
    void onChannelClose();

    /**
     * Callback fired once channel error happened.
     */
    void onChannelError(final String description);
  }
}

主要就是定義了如何處理connect, disconnect, 還有怎麼去註冊SDP和ICE candidate, 在確定好連接成功後, AppRTCClient要負責呼叫onConnectedToRoom來通知 CallActivity已經可以準備建立video call的後續流程, 且要負責處理如果Signal server(這邊是firebase)有傳來遠端的SDP跟ICE candidate, 要負責呼叫SignalingEvents對應的處理 (這邊一樣會叫到CallActivity, 而CallActivity則會使用PeerConnectionClient來處理需要傳遞給PeerConnection相關的參數)

這邊用Firebase處理Signaling的方式是監聽某一個key的改變, 有新的裝置連接, 註冊SDP, ICE Candidate, 就寫到這下面去, 這其實不是一個很好的方式, 因為這下面只要有值的改變, 就會觸發, 不像是WebSocket那個版本是一來一往的API calls, 而且你不知道每次觸發被更動的是哪一部分, 一開始發生了好幾次PeerConnection重複註冊SDP才讓我發現因為這原因被重複呼叫的問題

TURN server

WebRTC是P2P的, 因此如果不具備穿牆能力的話, 在牆外就會被擋掉, 一開始我本來想說試驗P2P而不走TURN Server穿牆的(因為我一時也懶得架一台), 結果測試時老是連不上, 後來才發現我阿呆, 我的測試環境是一台實體手機, 另一台是電腦上跑模擬器, 本以為兩個(手機, 電腦)是同一個區網沒問題, 後來才想到模擬器是在另一個虛擬網路, 因此還是有需要TURN server

如果不想架一台, 要怎辦? 用Google免錢的, 他們做了這個demo, 一定有! 因此就偷看了一下WebRTCClient的code跟傳輸內容,發現它跟https://networktraversal.googleapis.com/v1alpha/iceconfig?key=AIzaSyAJdh2HkajseEIltlZ3SIXO02Tze9sO3NY 去要TURN server list, 所以基本上只要照copy下面這段就好:

   private LinkedList<PeerConnection.IceServer> requestTurnServers(String url)
            throws IOException, JSONException {
        LinkedList<PeerConnection.IceServer> turnServers = new LinkedList<PeerConnection.IceServer>();
        Log.d(TAG, "Request TURN from: " + url);
        HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
        connection.setDoOutput(true);
        connection.setRequestProperty("REFERER", "https://appr.tc");
        connection.setConnectTimeout(TURN_HTTP_TIMEOUT_MS);
        connection.setReadTimeout(TURN_HTTP_TIMEOUT_MS);
        int responseCode = connection.getResponseCode();
        if (responseCode != 200) {
            throw new IOException("Non-200 response when requesting TURN server from " + url + " : "
                    + connection.getHeaderField(null));
        }
        InputStream responseStream = connection.getInputStream();
        String response = drainStream(responseStream);
        connection.disconnect();
        Log.d(TAG, "TURN response: " + response);
        JSONObject responseJSON = new JSONObject(response);
        JSONArray iceServers = responseJSON.getJSONArray("iceServers");
        for (int i = 0; i < iceServers.length(); ++i) {
            JSONObject server = iceServers.getJSONObject(i);
            JSONArray turnUrls = server.getJSONArray("urls");
            String username = server.has("username") ? server.getString("username") : "";
            String credential = server.has("credential") ? server.getString("credential") : "";
            for (int j = 0; j < turnUrls.length(); j++) {
                String turnUrl = turnUrls.getString(j);
                turnServers.add(new PeerConnection.IceServer(turnUrl, username, credential));
            }
        }
        return turnServers;
    }

    // Return the contents of an InputStream as a String.
    private static String drainStream(InputStream in) {
        Scanner s = new Scanner(in).useDelimiter("\\A");
        return s.hasNext() ? s.next() : "";
    }

把這邊拿來的list當ICE candidate, 就可以成功透過Google的TURN server去穿牆了(長久之計還是自己架一台吧)