目錄:
一.Functionality: 項(xiàng)目需求介紹
二.Architecture: 結(jié)構(gòu)介紹
三.代碼解讀
四.Test 解讀: 此項(xiàng)目的 test 很豐富
資源:
一.github 地址:
GithubBrowserSample - An advanced sample that uses the Architecture components, Dagger and the Github API. Requires Android Studio 3.0 canary 1
注意: 需要使用 Android Studio 3.0 canary 版本
二.Guide to App Architecture
Guide to App Architecture - This guide is for developers who are past the basics of building an app, and now want to know the best practices and recommended architecture for building robust, production-quality apps.
一.Functionality 功能:
The app is composed of 3 main screens.此 app 有3個(gè)頁(yè)面:
1.SearchFragment.java:
Allows you to search repositories on Github. ①Each search result is kept in the database in RepoSearchResult table where the list of repository IDs are denormalized into a single column. The actual Repo instances live in the Repo table.
允許您在 Github 上搜索 ropositories(庫(kù)). ①每個(gè)搜索結(jié)果保存在 RepoSearchResult 表中的數(shù)據(jù)庫(kù)里, 其中 repository IDs 列表被非規(guī)范為single column(單個(gè)列).②真實(shí)的 RepoEntity實(shí)例存于 Repo 表中.
Each time a new page is fetched, the same RepoSearchResult record in the Database is updated with the new list of repository ids.
每次獲取新頁(yè)面時(shí)谬哀,RepoSearchResult表中相同的記錄將使用新的 repository IDs列表進(jìn)行更新露氮。
NOTE The UI currently loads all Repo items at once, which would not perform well on lower end devices. Instead of manually writing lazy adapters, we've decided to wait until the built in support in Room is released.
注意 一旦UI加載所有Repo項(xiàng)目社裆,這在移動(dòng)設(shè)備上不能很好地運(yùn)行荔茬。與其手寫(xiě)懶加載適配器攒射,不如使用 Room。
①table RepoSearchResult:
② table Repo:
2. RepoFragment.java:
This fragment displays the details of a repository and its contributors.
此片段顯示存儲(chǔ)庫(kù)及其貢獻(xiàn)者的詳細(xì)信息。
details:
3.UserFragment.java
This fragment displays a user and their repositories.
此片段顯示用戶及其存儲(chǔ)庫(kù)。
table User:
二.Architecture
1.The final architecture
最終架構(gòu), 使用 repository 層, 獲取網(wǎng)絡(luò)數(shù)據(jù), 緩存網(wǎng)絡(luò)數(shù)據(jù)到內(nèi)存, 存儲(chǔ)網(wǎng)絡(luò)數(shù)據(jù)到數(shù)據(jù)庫(kù);
監(jiān)聽(tīng) LiveData 的變化, 自動(dòng)判斷 Activity/Fragment 的生命周期是 visible 還是 gone, 自動(dòng)更新界面.
2. 各個(gè) Module 介紹
①.Activity/Fragment:
??The UI controller that displays the data in the ViewModel and reacts to user interactions.顯示 ViewModel 中的數(shù)據(jù)并相應(yīng) UI 的UI 控制器
②.ViewModel :
??The class that prepares the data for the UI.為 UI 準(zhǔn)備數(shù)據(jù)的類(lèi)
③.Repository
??Repository modules are responsible for handling data operations.You can consider them as mediators between different data sources (persistent model, web service, cache, etc.).
??Repository(庫(kù))模塊負(fù)責(zé)處理數(shù)據(jù)的操作.你可以把它們當(dāng)作(持久化模型别渔,Web服務(wù),緩存等)不同的數(shù)據(jù)源之間的調(diào)停。
而關(guān)注點(diǎn)分離原則提供的最大好處就是可測(cè)性.
④.RoomDatabase
?? database 的arch 實(shí)現(xiàn), 持久化數(shù)據(jù)層
@Entity
數(shù)據(jù)庫(kù)對(duì)象實(shí)體類(lèi)
@DAO
數(shù)據(jù)訪問(wèn)對(duì)象
⑤.Webservice
?? use the Retrofit library to access our backend
??Activity/Fragment 或者 ViewModel 可以直接使用 webservice 獲取數(shù)據(jù), 但是違背了ViewModel 層的separation of concerns關(guān)注點(diǎn)分離原則.所以ViewModel 委托(delegate)這件事給 Repository Module.
⑥.DI
依賴(lài)注入. 管理組件之間的依賴(lài)關(guān)系,簡(jiǎn)單化.
⑦.對(duì)數(shù)據(jù)操作的輔助類(lèi)
NetworkBoundResource.java可以被用于多個(gè)項(xiàng)目
觀察數(shù)據(jù)庫(kù)的決策樹(shù): 是否請(qǐng)求服務(wù)器(fetch data).
三.代碼解讀
Entity
我們使用@Entity注釋數(shù)據(jù)庫(kù)中的表
①.UserEntity.java
注意: 變量使用的 public 減少代碼而不是使用 private + get() set() 方法
@Entity(primaryKeys = "login")
public class User {
@SerializedName("login")
public final String login;
@SerializedName("avatar_url")
public final String avatarUrl;
@SerializedName("name")
public final String name;
@SerializedName("company")
public final String company;
@SerializedName("repos_url")
public final String reposUrl;
@SerializedName("blog")
public final String blog;
public User(String login, String avatarUrl, String name, String company,
String reposUrl, String blog) {
this.login = login;
this.avatarUrl = avatarUrl;
this.name = name;
this.company = company;
this.reposUrl = reposUrl;
this.blog = blog;
}
}
②.RepoEntity.java
此處把 owner_login 和 owner_url 設(shè)計(jì)為 Owner 內(nèi)部類(lèi), 后面我看為什么要這么設(shè)計(jì)?
// Owner包擴(kuò) owner_login 和 owner_url 2個(gè)字段
@Entity(indices = {@Index("id"), @Index("owner_login")},
primaryKeys = {"name", "owner_login"})
public class Repo {
public static final int UNKNOWN_ID = -1;
public final int id;
@SerializedName("name")
public final String name;
@SerializedName("full_name")
public final String fullName;
@SerializedName("description")
public final String description;
@SerializedName("stargazers_count")
public final int stars;
@SerializedName("owner")
@Embedded(prefix = "owner_")// 注意這句話包擴(kuò) owner_login 和 owner_url 2個(gè)字段
public final Owner owner;
public Repo(int id, String name, String fullName, String description, Owner owner, int stars) {
this.id = id;
this.name = name;
this.fullName = fullName;
this.description = description;
this.owner = owner;
this.stars = stars;
}
public static class Owner {
@SerializedName("login")
public final String login;
@SerializedName("url")
public final String url;
public Owner(String login, String url) {
this.login = login;
this.url = url;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Owner owner = (Owner) o;
if (login != null ? !login.equals(owner.login) : owner.login != null) {
return false;
}
return url != null ? url.equals(owner.url) : owner.url == null;
}
@Override
public int hashCode() {
int result = login != null ? login.hashCode() : 0;
result = 31 * result + (url != null ? url.hashCode() : 0);
return result;
}
}
}
③.RepoSearchResultEntity.java
@Entity(primaryKeys = {"query"})
@TypeConverters(GithubTypeConverters.class)
public class RepoSearchResult {
public final String query;
public final List<Integer> repoIds;
public final int totalCount;
@Nullable
public final Integer next;
public RepoSearchResult(String query, List<Integer> repoIds, int totalCount,
Integer next) {
this.query = query;
this.repoIds = repoIds;
this.totalCount = totalCount;
this.next = next;
}
}
④.ContributorEntity.java
此處使用了 private get set 方法聲明使用變量 和上面的做對(duì)比
構(gòu)造方法中缺少 repoName 和 repoOwner, 我們注意找到代碼中如何給這2個(gè)變量賦值?
@Entity(primaryKeys = {"repoName", "repoOwner", "login"},
foreignKeys = @ForeignKey(entity = Repo.class,
parentColumns = {"name", "owner_login"},
childColumns = {"repoName", "repoOwner"},
onUpdate = ForeignKey.CASCADE,
deferred = true))
public class Contributor {
@SerializedName("login")
private final String login;
@SerializedName("contributions")
private final int contributions;
@SerializedName("avatar_url")
private final String avatarUrl;
private String repoName;
private String repoOwner;
public Contributor(String login, int contributions, String avatarUrl) {
this.login = login;
this.contributions = contributions;
this.avatarUrl = avatarUrl;
}
public void setRepoName(String repoName) {
this.repoName = repoName;
}
public void setRepoOwner(String repoOwner) {
this.repoOwner = repoOwner;
}
public String getLogin() {
return login;
}
public int getContributions() {
return contributions;
}
public String getAvatarUrl() {
return avatarUrl;
}
public String getRepoName() {
return repoName;
}
public String getRepoOwner() {
return repoOwner;
}
}
Database
Notice that is abstract.Room automatically provides an implementation of it.
請(qǐng)注意 MyDatabase 是 abstract 的, room 會(huì)自動(dòng)提供它的具體的實(shí)現(xiàn).
使用注解@Database生成庫(kù), entities 生成一個(gè)或多個(gè)表, version 修改版本
@Database(entities = {User.class, Repo.class, Contributor.class,
RepoSearchResult.class}, version = 3)
public abstract class GithubDb extends RoomDatabase {
abstract public UserDao userDao();
abstract public RepoDao repoDao();
}
DAO
使用@Dao 注釋, 對(duì)應(yīng)數(shù)據(jù)庫(kù)的 CRUD 操作
①.RepoDao.java
@Dao
public abstract class RepoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void insert(Repo... repos);
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void insertContributors(List<Contributor> contributors);
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void insertRepos(List<Repo> repositories);
@Insert(onConflict = OnConflictStrategy.IGNORE)
public abstract long createRepoIfNotExists(Repo repo);
@Query("SELECT * FROM repo WHERE owner_login = :login AND name = :name")
public abstract LiveData<Repo> load(String login, String name);
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
@Query("SELECT login, avatarUrl, contributions FROM contributor "
+ "WHERE repoName = :name AND repoOwner = :owner "
+ "ORDER BY contributions DESC")
public abstract LiveData<List<Contributor>> loadContributors(String owner, String name);
@Query("SELECT * FROM Repo "
+ "WHERE owner_login = :owner "
+ "ORDER BY stars DESC")
public abstract LiveData<List<Repo>> loadRepositories(String owner);
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void insert(RepoSearchResult result);
@Query("SELECT * FROM RepoSearchResult WHERE query = :query")
public abstract LiveData<RepoSearchResult> search(String query);
public LiveData<List<Repo>> loadOrdered(List<Integer> repoIds) {
SparseIntArray order = new SparseIntArray();
int index = 0;
for (Integer repoId : repoIds) {
order.put(repoId, index++);
}
return Transformations.map(loadById(repoIds), repositories -> {
Collections.sort(repositories, (r1, r2) -> {
int pos1 = order.get(r1.id);
int pos2 = order.get(r2.id);
return pos1 - pos2;
});
return repositories;
});
}
@Query("SELECT * FROM Repo WHERE id in (:repoIds)")
protected abstract LiveData<List<Repo>> loadById(List<Integer> repoIds);
@Query("SELECT * FROM RepoSearchResult WHERE query = :query")
public abstract RepoSearchResult findSearchResult(String query);
}
②.UserDao.java
@Dao
public interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(User user);
@Query("SELECT * FROM user WHERE login = :login")
LiveData<User> findByLogin(String login);
}
Repository
repository 層 屬于對(duì)數(shù)據(jù)操作的封裝層, 包括網(wǎng)絡(luò)獲取數(shù)據(jù), 和數(shù)據(jù)庫(kù)中的數(shù)據(jù)
①.UserRepository.java
注意 loadUser()方法中的 NetworkBoundResource類(lèi), 后面再說(shuō)
/**
* Repository that handles User objects.
*/
@Singleton
public class UserRepository {
private final UserDao userDao;
private final GithubService githubService;
private final AppExecutors appExecutors;
@Inject
UserRepository(AppExecutors appExecutors, UserDao userDao, GithubService githubService) {
this.userDao = userDao;
this.githubService = githubService;
this.appExecutors = appExecutors;
}
public LiveData<Resource<User>> loadUser(String login) {
return new NetworkBoundResource<User,User>(appExecutors) {
@Override
protected void saveCallResult(@NonNull User item) {
userDao.insert(item);
}
@Override
protected boolean shouldFetch(@Nullable User data) {
return data == null;
}
@NonNull
@Override
protected LiveData<User> loadFromDb() {
return userDao.findByLogin(login);
}
@NonNull
@Override
protected LiveData<ApiResponse<User>> createCall() {
return githubService.getUser(login);
}
}.asLiveData();
}
}
②.RepoRepository.java
也需要注意 NetworkBoundResource 類(lèi)
@Singleton
public class RepoRepository {
private final GithubDb db;
private final RepoDao repoDao;
private final GithubService githubService;
private final AppExecutors appExecutors;
private RateLimiter<String> repoListRateLimit = new RateLimiter<>(10, TimeUnit.MINUTES);
@Inject
public RepoRepository(AppExecutors appExecutors, GithubDb db, RepoDao repoDao,
GithubService githubService) {
this.db = db;
this.repoDao = repoDao;
this.githubService = githubService;
this.appExecutors = appExecutors;
}
public LiveData<Resource<List<Repo>>> loadRepos(String owner) {
return new NetworkBoundResource<List<Repo>, List<Repo>>(appExecutors) {
@Override
protected void saveCallResult(@NonNull List<Repo> item) {
Logger.e("saveCallResult()");
repoDao.insertRepos(item);
}
@Override
protected boolean shouldFetch(@Nullable List<Repo> data) {
Logger.e("shouldFetch()");
return data == null || data.isEmpty() || repoListRateLimit.shouldFetch(owner);
}
@NonNull
@Override
protected LiveData<List<Repo>> loadFromDb() {
Logger.e("loadFromDb()");
return repoDao.loadRepositories(owner);
}
@NonNull
@Override
protected LiveData<ApiResponse<List<Repo>>> createCall() {
Logger.e("createCall()");
return githubService.getRepos(owner);
}
@Override
protected void onFetchFailed() {
Logger.e("onFetchFailed()");
repoListRateLimit.reset(owner);
}
}.asLiveData();
}
public LiveData<Resource<Repo>> loadRepo(String owner, String name) {
return new NetworkBoundResource<Repo, Repo>(appExecutors) {
@Override
protected void saveCallResult(@NonNull Repo item) {
Logger.e("saveCallResult()");
repoDao.insert(item);
}
@Override
protected boolean shouldFetch(@Nullable Repo data) {
Logger.e("shouldFetch()");
return data == null;
}
@NonNull
@Override
protected LiveData<Repo> loadFromDb() {
Logger.e("loadFromDb()");
return repoDao.load(owner, name);
}
@NonNull
@Override
protected LiveData<ApiResponse<Repo>> createCall() {
Logger.e("createCall()");
return githubService.getRepo(owner, name);
}
}.asLiveData();
}
public LiveData<Resource<List<Contributor>>> loadContributors(String owner, String name) {
return new NetworkBoundResource<List<Contributor>, List<Contributor>>(appExecutors) {
@Override
protected void saveCallResult(@NonNull List<Contributor> contributors) {
Logger.e("saveCallResult()");
for (Contributor contributor : contributors) {
contributor.setRepoName(name);
contributor.setRepoOwner(owner);
}
db.beginTransaction();
try {
repoDao.createRepoIfNotExists(new Repo(Repo.UNKNOWN_ID,
name, owner + "/" + name, "",
new Repo.Owner(owner, null), 0));
repoDao.insertContributors(contributors);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
Timber.d("rece saved contributors to db");
}
@Override
protected boolean shouldFetch(@Nullable List<Contributor> data) {
Logger.e("shouldFetch()");
Timber.d("rece contributor list from db: %s", data);
return data == null || data.isEmpty();
}
@NonNull
@Override
protected LiveData<List<Contributor>> loadFromDb() {
Logger.e("loadFromDb()");
return repoDao.loadContributors(owner, name);
}
@NonNull
@Override
protected LiveData<ApiResponse<List<Contributor>>> createCall() {
Logger.e("createCall()");
return githubService.getContributors(owner, name);
}
}.asLiveData();
}
public LiveData<Resource<Boolean>> searchNextPage(String query) {
Logger.e("searchNextPage()");
FetchNextSearchPageTask fetchNextSearchPageTask = new FetchNextSearchPageTask(
query, githubService, db);
appExecutors.networkIO().execute(fetchNextSearchPageTask);
return fetchNextSearchPageTask.getLiveData();
}
public LiveData<Resource<List<Repo>>> search(String query) {
Logger.e("search()");
return new NetworkBoundResource<List<Repo>, RepoSearchResponse>(appExecutors) {
@Override
protected void saveCallResult(@NonNull RepoSearchResponse item) {
Logger.e("saveCallResult");
List<Integer> repoIds = item.getRepoIds();
RepoSearchResult repoSearchResult = new RepoSearchResult(
query, repoIds, item.getTotal(), item.getNextPage());
db.beginTransaction();
try {
repoDao.insertRepos(item.getItems());
repoDao.insert(repoSearchResult);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
@Override
protected boolean shouldFetch(@Nullable List<Repo> data) {
Logger.e("shouldFetch");
return data == null;
}
@NonNull
@Override
protected LiveData<List<Repo>> loadFromDb() {
Logger.e("loadFromDb()");
return Transformations.switchMap(repoDao.search(query), searchData -> {
if (searchData == null) {
return AbsentLiveData.create();
} else {
return repoDao.loadOrdered(searchData.repoIds);
}
});
}
@NonNull
@Override
protected LiveData<ApiResponse<RepoSearchResponse>> createCall() {
Logger.e("createCall");
return githubService.searchRepos(query);
}
@Override
protected RepoSearchResponse processResponse(ApiResponse<RepoSearchResponse> response) {
Logger.e("processResponse");
RepoSearchResponse body = response.body;
if (body != null) {
body.setNextPage(response.getNextPage());
}
return body;
}
}.asLiveData();
}
}
NetworkBoundResource.java
注意構(gòu)造器中的被注釋的代碼, 此部分是源碼, 不使用lambda 之后是注釋后上面的部分,
我們能更清楚的看清 addSource的第2個(gè)參數(shù)為 observer 中有 onChanged() 方法, onChanged()方法比較重要,看下面的源碼注釋,我們可以知道Called when the data is changed.. 此方法是通過(guò)我們注冊(cè)觀察這個(gè)數(shù)據(jù), 期望它變化的時(shí)候告訴我們.
此輔助類(lèi)哎媚,我們可以在多個(gè)地方被重用喇伯。
interface Observer<T> code:
package android.arch.lifecycle;
import android.support.annotation.Nullable;
/**
* A simple callback that can receive from {@link LiveData}.
*
* @param <T> The type of the parameter
*
* @see LiveData LiveData - for a usage description.
*/
public interface Observer<T> {
/**
* Called when the data is changed.
* @param t The new data
*/
void onChanged(@Nullable T t);
}
public abstract class NetworkBoundResource<ResultType, RequestType> {
private final AppExecutors appExecutors;
private final MediatorLiveData<Resource<ResultType>> result = new MediatorLiveData<>();
@MainThread
NetworkBoundResource(AppExecutors appExecutors) {
Logger.e("new NetworkBoundResource() 對(duì)象");
this.appExecutors = appExecutors;
result.setValue(Resource.loading(null));
LiveData<ResultType> dbSource = loadFromDb();
//從此部分開(kāi)始
result.addSource(dbSource, new Observer<ResultType>() {
@Override
public void onChanged(@Nullable ResultType data) {
result.removeSource(dbSource);
if (shouldFetch(data)) {
Logger.e("需要請(qǐng)求網(wǎng)絡(luò)");
fetchFromNetwork(dbSource);
} else {
Logger.e("不需要請(qǐng)求網(wǎng)絡(luò)");
result.addSource(dbSource, new Observer<ResultType>() {
@Override
public void onChanged(@Nullable ResultType newData) {
result.setValue(Resource.success(newData));
}
});
}
}
});
//此部分結(jié)束
// result.addSource(dbSource, data -> {
// result.removeSource(dbSource);
// if (shouldFetch(data)) {
// Logger.e("需要請(qǐng)求網(wǎng)絡(luò)");
// fetchFromNetwork(dbSource);
// } else {
// Logger.e("不需要請(qǐng)求網(wǎng)絡(luò)");
// result.addSource(dbSource, newData -> result.setValue(Resource.success(newData)));
// }
// });
}
private void fetchFromNetwork(final LiveData<ResultType> dbSource) {
Logger.e("fetchFromNetwork");
LiveData<ApiResponse<RequestType>> apiResponse = createCall();
// we re-attach dbSource as a new source, it will dispatch its latest value quickly
result.addSource(dbSource, newData -> result.setValue(Resource.loading(newData)));
result.addSource(apiResponse, response -> {
result.removeSource(apiResponse);
result.removeSource(dbSource);
//noinspection ConstantConditions
if (response.isSuccessful()) {
Logger.e("fetchFromNetwork 返回成功");
appExecutors.diskIO().execute(() -> {
saveCallResult(processResponse(response));
appExecutors.mainThread().execute(() ->
// we specially request a new live data,
// otherwise we will get immediately last cached value,
// which may not be updated with latest results received from network.
result.addSource(loadFromDb(),
newData -> result.setValue(Resource.success(newData)))
);
});
} else {
Logger.e("");
onFetchFailed();
result.addSource(dbSource,
newData -> result.setValue(Resource.error(response.errorMessage, newData)));
}
});
}
protected void onFetchFailed() {
Logger.e("");
}
public LiveData<Resource<ResultType>> asLiveData() {
Logger.e("asLiveData");
return result;
}
@WorkerThread
protected RequestType processResponse(ApiResponse<RequestType> response) {
Logger.e("");
return response.body;
}
@WorkerThread
protected abstract void saveCallResult(@NonNull RequestType item);
@MainThread
protected abstract boolean shouldFetch(@Nullable ResultType data);
@NonNull
@MainThread
protected abstract LiveData<ResultType> loadFromDb();
@NonNull
@MainThread
protected abstract LiveData<ApiResponse<RequestType>> createCall();
}
GithubService.java
處理和網(wǎng)絡(luò)交互的工具
/**
* REST API access points
*/
public interface GithubService {
@GET("users/{login}")
LiveData<ApiResponse<User>> getUser(@Path("login") String login);
@GET("users/{login}/repos")
LiveData<ApiResponse<List<Repo>>> getRepos(@Path("login") String login);
@GET("repos/{owner}/{name}")
LiveData<ApiResponse<Repo>> getRepo(@Path("owner") String owner, @Path("name") String name);
@GET("repos/{owner}/{name}/contributors")
LiveData<ApiResponse<List<Contributor>>> getContributors(@Path("owner") String owner, @Path("name") String name);
@GET("search/repositories")
LiveData<ApiResponse<RepoSearchResponse>> searchRepos(@Query("q") String query);
@GET("search/repositories")
Call<RepoSearchResponse> searchRepos(@Query("q") String query, @Query("page") int page);
}
ApiResponse
/**
* Common class used by API responses.
* @param <T>
*/
public class ApiResponse<T> {
private static final Pattern LINK_PATTERN = Pattern
.compile("<([^>]*)>[\\s]*;[\\s]*rel=\"([a-zA-Z0-9]+)\"");
private static final Pattern PAGE_PATTERN = Pattern.compile("page=(\\d)+");
private static final String NEXT_LINK = "next";
public final int code;
@Nullable
public final T body;
@Nullable
public final String errorMessage;
@NonNull
public final Map<String, String> links;
public ApiResponse(Throwable error) {
code = 500;
body = null;
errorMessage = error.getMessage();
links = Collections.emptyMap();
}
public ApiResponse(Response<T> response) {
code = response.code();
if(response.isSuccessful()) {
body = response.body();
errorMessage = null;
} else {
String message = null;
if (response.errorBody() != null) {
try {
message = response.errorBody().string();
} catch (IOException ignored) {
Timber.e(ignored, "error while parsing response");
}
}
if (message == null || message.trim().length() == 0) {
message = response.message();
}
errorMessage = message;
body = null;
}
String linkHeader = response.headers().get("link");
if (linkHeader == null) {
links = Collections.emptyMap();
} else {
links = new ArrayMap<>();
Matcher matcher = LINK_PATTERN.matcher(linkHeader);
while (matcher.find()) {
int count = matcher.groupCount();
if (count == 2) {
links.put(matcher.group(2), matcher.group(1));
}
}
}
}
public boolean isSuccessful() {
return code >= 200 && code < 300;
}
public Integer getNextPage() {
String next = links.get(NEXT_LINK);
if (next == null) {
return null;
}
Matcher matcher = PAGE_PATTERN.matcher(next);
if (!matcher.find() || matcher.groupCount() != 1) {
return null;
}
try {
return Integer.parseInt(matcher.group(1));
} catch (NumberFormatException ex) {
Timber.w("cannot parse next page from %s", next);
return null;
}
}
}
RepoSearchResponse.java
/**
* POJO to hold repo search responses. This is different from the Entity in the database because
* we are keeping a search result in 1 row and denormalizing list of results into a single column.
*/
public class RepoSearchResponse {
@SerializedName("total_count")
private int total;
@SerializedName("items")
private List<Repo> items;
private Integer nextPage;
public int getTotal() {
return total;
}
public void setTotal(int total) {
this.total = total;
}
public List<Repo> getItems() {
return items;
}
public void setItems(List<Repo> items) {
this.items = items;
}
public void setNextPage(Integer nextPage) {
this.nextPage = nextPage;
}
public Integer getNextPage() {
return nextPage;
}
@NonNull
public List<Integer> getRepoIds() {
List<Integer> repoIds = new ArrayList<>();
for (Repo repo : items) {
repoIds.add(repo.id);
}
return repoIds;
}
}
DataBinding
BindingAdapters.java
綁定了"visibleGone"關(guān)鍵字,在 xml 和 java 之間的關(guān)系, 我們可以搜索"visibleGone"和"showHide"看看都在哪些地方調(diào)用了
/**
* Data Binding adapters specific to the app.
*/
public class BindingAdapters {
@BindingAdapter("visibleGone")
public static void showHide(View view, boolean show) {
view.setVisibility(show ? View.VISIBLE : View.GONE);
}
}
FragmentBindingAdapters.java
綁定了"imageUrl"關(guān)鍵字,在 xml 和 java 之間的關(guān)系, 我們也可以搜索"imageUrl"和"bindImage"看看都在哪些地方調(diào)用了
/**
* Binding adapters that work with a fragment instance.
*/
public class FragmentBindingAdapters {
final Fragment fragment;
@Inject
public FragmentBindingAdapters(Fragment fragment) {
this.fragment = fragment;
}
@BindingAdapter("imageUrl")
public void bindImage(ImageView imageView, String url) {
Glide.with(fragment).load(url).into(imageView);
}
}
FragmentDataBindingComponent.java
/**
* A Data Binding Component implementation for fragments.
*/
public class FragmentDataBindingComponent implements DataBindingComponent {
private final FragmentBindingAdapters adapter;
public FragmentDataBindingComponent(Fragment fragment) {
this.adapter = new FragmentBindingAdapters(fragment);
}
@Override
public FragmentBindingAdapters getFragmentBindingAdapters() {
return adapter;
}
}
DI
AppComponent.java
@Singleton
@Component(modules = {
AndroidInjectionModule.class,
AppModule.class,
MainActivityModule.class
})
public interface AppComponent {
@Component.Builder
interface Builder {
@BindsInstance Builder application(Application application);
AppComponent build();
}
void inject(GithubApp githubApp);
}
AppModule.java
@Module(includes = ViewModelModule.class)
class AppModule {
@Singleton @Provides
GithubService provideGithubService() {
return new Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(new LiveDataCallAdapterFactory())
.build()
.create(GithubService.class);
}
@Singleton @Provides
GithubDb provideDb(Application app) {
return Room.databaseBuilder(app, GithubDb.class,"github.db").build();
}
@Singleton @Provides
UserDao provideUserDao(GithubDb db) {
return db.userDao();
}
@Singleton @Provides
RepoDao provideRepoDao(GithubDb db) {
return db.repoDao();
}
}
MainActivityModule.java
@Module
public abstract class MainActivityModule {
@ContributesAndroidInjector(modules = FragmentBuildersModule.class)
abstract MainActivity contributeMainActivity();
}
ViewModelModule.java
@Module
abstract class ViewModelModule {
@Binds
@IntoMap
@ViewModelKey(UserViewModel.class)
abstract ViewModel bindUserViewModel(UserViewModel userViewModel);
@Binds
@IntoMap
@ViewModelKey(SearchViewModel.class)
abstract ViewModel bindSearchViewModel(SearchViewModel searchViewModel);
@Binds
@IntoMap
@ViewModelKey(RepoViewModel.class)
abstract ViewModel bindRepoViewModel(RepoViewModel repoViewModel);
@Binds
abstract ViewModelProvider.Factory bindViewModelFactory(GithubViewModelFactory factory);
}
viewModel
GithubViewModelFactory.java
@Singleton
public class GithubViewModelFactory implements ViewModelProvider.Factory {
private final Map<Class<? extends ViewModel>, Provider<ViewModel>> creators;
@Inject
public GithubViewModelFactory(Map<Class<? extends ViewModel>, Provider<ViewModel>> creators) {
this.creators = creators;
}
@SuppressWarnings("unchecked")
@Override
public <T extends ViewModel> T create(Class<T> modelClass) {
Provider<? extends ViewModel> creator = creators.get(modelClass);
if (creator == null) {
for (Map.Entry<Class<? extends ViewModel>, Provider<ViewModel>> entry : creators.entrySet()) {
if (modelClass.isAssignableFrom(entry.getKey())) {
creator = entry.getValue();
break;
}
}
}
if (creator == null) {
throw new IllegalArgumentException("unknown model class " + modelClass);
}
try {
return (T) creator.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
RepoViewModel.java
public class RepoViewModel extends ViewModel {
@VisibleForTesting
final MutableLiveData<RepoId> repoId;
private final LiveData<Resource<Repo>> repo;
private final LiveData<Resource<List<Contributor>>> contributors;
@Inject
public RepoViewModel(RepoRepository repository) {
this.repoId = new MutableLiveData<>();
repo = Transformations.switchMap(repoId, input -> {
if (input.isEmpty()) {
return AbsentLiveData.create();
}
return repository.loadRepo(input.owner, input.name);
});
contributors = Transformations.switchMap(repoId, input -> {
if (input.isEmpty()) {
return AbsentLiveData.create();
} else {
return repository.loadContributors(input.owner, input.name);
}
});
}
public LiveData<Resource<Repo>> getRepo() {
return repo;
}
public LiveData<Resource<List<Contributor>>> getContributors() {
return contributors;
}
public void retry() {
RepoId current = repoId.getValue();
if (current != null && !current.isEmpty()) {
repoId.setValue(current);
}
}
void setId(String owner, String name) {
RepoId update = new RepoId(owner, name);
if (Objects.equals(repoId.getValue(), update)) {
return;
}
repoId.setValue(update);
}
@VisibleForTesting
static class RepoId {
public final String owner;
public final String name;
RepoId(String owner, String name) {
this.owner = owner == null ? null : owner.trim();
this.name = name == null ? null : name.trim();
}
boolean isEmpty() {
return owner == null || name == null || owner.length() == 0 || name.length() == 0;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
RepoId repoId = (RepoId) o;
if (owner != null ? !owner.equals(repoId.owner) : repoId.owner != null) {
return false;
}
return name != null ? name.equals(repoId.name) : repoId.name == null;
}
@Override
public int hashCode() {
int result = owner != null ? owner.hashCode() : 0;
result = 31 * result + (name != null ? name.hashCode() : 0);
return result;
}
}
}
SearchViewModel.java
public class SearchViewModel extends ViewModel {
private final MutableLiveData<String> query = new MutableLiveData<>();
private final LiveData<Resource<List<Repo>>> results;
private final NextPageHandler nextPageHandler;
@Inject
SearchViewModel(RepoRepository repoRepository) {
Logger.e("init SearchViewModel()");
nextPageHandler = new NextPageHandler(repoRepository);
results = Transformations.switchMap(query, search -> {
if (search == null || search.trim().length() == 0) {
Logger.e("初始化 LiveData");
return AbsentLiveData.create();
} else {
Logger.e("search LiveData");
return repoRepository.search(search);
}
});
}
LiveData<Resource<List<Repo>>> getResults() {
Logger.e("getResults()");
return results;
}
public void setQuery(@NonNull String originalInput) {
String input = originalInput.toLowerCase(Locale.getDefault()).trim();
if (Objects.equals(input, query.getValue())) {
Logger.e("和上次一樣, 就不搜索了");
return;
}
nextPageHandler.reset();
query.setValue(input);
}
LiveData<LoadMoreState> getLoadMoreStatus() {
return nextPageHandler.getLoadMoreState();
}
void loadNextPage() {
Logger.e("loadNextPage()");
String value = query.getValue();
if (value == null || value.trim().length() == 0) {
return;
}
nextPageHandler.queryNextPage(value);
}
void refresh() {
if (query.getValue() != null) {
query.setValue(query.getValue());
}
}
static class LoadMoreState {
private final boolean running;
private final String errorMessage;
private boolean handledError = false;
LoadMoreState(boolean running, String errorMessage) {
this.running = running;
this.errorMessage = errorMessage;
}
boolean isRunning() {
return running;
}
String getErrorMessage() {
return errorMessage;
}
String getErrorMessageIfNotHandled() {
if (handledError) {
return null;
}
handledError = true;
return errorMessage;
}
}
@VisibleForTesting
static class NextPageHandler implements Observer<Resource<Boolean>> {
@Nullable
private LiveData<Resource<Boolean>> nextPageLiveData;
private final MutableLiveData<LoadMoreState> loadMoreState = new MutableLiveData<>();
private String query;
private final RepoRepository repository;
@VisibleForTesting
boolean hasMore;
@VisibleForTesting
NextPageHandler(RepoRepository repository) {
Logger.e("init NextPageHandler()");
this.repository = repository;
reset();
}
void queryNextPage(String query) {
Logger.e("queryNextPage()");
if (Objects.equals(this.query, query)) {
return;
}
unregister();
this.query = query;
nextPageLiveData = repository.searchNextPage(query);
loadMoreState.setValue(new LoadMoreState(true, null));
//noinspection ConstantConditions
nextPageLiveData.observeForever(this);
}
@Override
public void onChanged(@Nullable Resource<Boolean> result) {
if (result == null) {
reset();
} else {
Logger.e("有 result");
switch (result.status) {
case SUCCESS:
hasMore = Boolean.TRUE.equals(result.data);
unregister();
loadMoreState.setValue(new LoadMoreState(false, null));
break;
case ERROR:
hasMore = true;
unregister();
loadMoreState.setValue(new LoadMoreState(false,
result.message));
break;
}
}
}
private void unregister() {
if (nextPageLiveData != null) {
nextPageLiveData.removeObserver(this);
nextPageLiveData = null;
if (hasMore) {
query = null;
}
}
}
private void reset() {
unregister();
hasMore = true;
loadMoreState.setValue(new LoadMoreState(false, null));
}
MutableLiveData<LoadMoreState> getLoadMoreState() {
Logger.e("getLoadMoreState()");
return loadMoreState;
}
}
}
UserViewModel.java
public class UserViewModel extends ViewModel {
@VisibleForTesting
final MutableLiveData<String> login = new MutableLiveData<>();
private final LiveData<Resource<List<Repo>>> repositories;
private final LiveData<Resource<User>> user;
@SuppressWarnings("unchecked")
@Inject
public UserViewModel(UserRepository userRepository, RepoRepository repoRepository) {
user = Transformations.switchMap(login, login -> {
if (login == null) {
return AbsentLiveData.create();
} else {
return userRepository.loadUser(login);
}
});
repositories = Transformations.switchMap(login, login -> {
if (login == null) {
return AbsentLiveData.create();
} else {
return repoRepository.loadRepos(login);
}
});
}
void setLogin(String login) {
if (Objects.equals(this.login.getValue(), login)) {
return;
}
this.login.setValue(login);
}
LiveData<Resource<User>> getUser() {
return user;
}
LiveData<Resource<List<Repo>>> getRepositories() {
return repositories;
}
void retry() {
if (this.login.getValue() != null) {
this.login.setValue(this.login.getValue());
}
}
}
四.Testing
The project uses both instrumentation tests that run on the device and local unit tests that run on your computer. To run both of them and generate a coverage report, you can run:
./gradlew fullCoverageReport (requires a connected device or an emulator)
Device Tests
Device Tests
UI Tests
The projects uses Espresso for UI testing. Since each fragment is limited to a ViewModel, each test mocks related ViewModel to run the tests.
該項(xiàng)目使用 Espresso 進(jìn)行 UI 測(cè)試.因?yàn)槊總€(gè) fragment 都受限于ViewModel, 所有每個(gè) test 都會(huì)模擬相關(guān)的 ViewModel 進(jìn)行 test.
@RunWith(AndroidJUnit4.class)
public class RepoFragmentTest {
@Before
public void init() {
repoFragment = RepoFragment.create("a", "b");
//此處就是使用 mock 進(jìn)行的模擬
viewModel = mock(RepoViewModel.class);
fragmentBindingAdapters = mock(FragmentBindingAdapters.class);
navigationController = mock(NavigationController.class);
when(viewModel.getRepo()).thenReturn(repo);
when(viewModel.getContributors()).thenReturn(contributors);
repoFragment.viewModelFactory = ViewModelUtil.createFor(viewModel);
repoFragment.dataBindingComponent = () -> fragmentBindingAdapters;
repoFragment.navigationController = navigationController;
activityRule.getActivity().setFragment(repoFragment);
}
}
Database Tests
The project creates an in memory database for each database test but still runs them on the device.
UserDaoTest.java
@RunWith(AndroidJUnit4.class)
public class UserDaoTest extends DbTest {
@Test
public void insertAndLoad() throws InterruptedException {
final User user = TestUtil.createUser("foo");
db.userDao().insert(user);
final User loaded = getValue(db.userDao().findByLogin(user.login));
assertThat(loaded.login, is("foo"));
final User replacement = TestUtil.createUser("foo2");
db.userDao().insert(replacement);
final User loadedReplacement = getValue(db.userDao().findByLogin("foo2"));
assertThat(loadedReplacement.login, is("foo2"));
}
}
Local Unit Tests
ViewModel Tests
Each ViewModel is tested using local unit tests with mock Repository implementations.
UserViewModelTest.java
@SuppressWarnings("unchecked")
@RunWith(JUnit4.class)
public class UserViewModelTest {
@Rule
public InstantTaskExecutorRule instantExecutorRule = new InstantTaskExecutorRule();
private UserViewModel userViewModel;
private UserRepository userRepository;
private RepoRepository repoRepository;
@Before
public void setup() {
userRepository = mock(UserRepository.class);
repoRepository = mock(RepoRepository.class);
userViewModel = new UserViewModel(userRepository, repoRepository);
}
@Test
public void testNull() {
assertThat(userViewModel.getUser(), notNullValue());
verify(userRepository, never()).loadUser(anyString());
userViewModel.setLogin("foo");
verify(userRepository, never()).loadUser(anyString());
}
@Test
public void testCallRepo() {
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
userViewModel.getUser().observeForever(mock(Observer.class));
userViewModel.setLogin("abc");
verify(userRepository).loadUser(captor.capture());
assertThat(captor.getValue(), is("abc"));
reset(userRepository);
userViewModel.setLogin("ddd");
verify(userRepository).loadUser(captor.capture());
assertThat(captor.getValue(), is("ddd"));
}
@Test
public void sendResultToUI() {
MutableLiveData<Resource<User>> foo = new MutableLiveData<>();
MutableLiveData<Resource<User>> bar = new MutableLiveData<>();
when(userRepository.loadUser("foo")).thenReturn(foo);
when(userRepository.loadUser("bar")).thenReturn(bar);
Observer<Resource<User>> observer = mock(Observer.class);
userViewModel.getUser().observeForever(observer);
userViewModel.setLogin("foo");
verify(observer, never()).onChanged(any(Resource.class));
User fooUser = TestUtil.createUser("foo");
Resource<User> fooValue = Resource.success(fooUser);
foo.setValue(fooValue);
verify(observer).onChanged(fooValue);
reset(observer);
User barUser = TestUtil.createUser("bar");
Resource<User> barValue = Resource.success(barUser);
bar.setValue(barValue);
userViewModel.setLogin("bar");
verify(observer).onChanged(barValue);
}
@Test
public void loadRepositories() {
userViewModel.getRepositories().observeForever(mock(Observer.class));
verifyNoMoreInteractions(repoRepository);
userViewModel.setLogin("foo");
verify(repoRepository).loadRepos("foo");
reset(repoRepository);
userViewModel.setLogin("bar");
verify(repoRepository).loadRepos("bar");
verifyNoMoreInteractions(userRepository);
}
@Test
public void retry() {
userViewModel.setLogin("foo");
verifyNoMoreInteractions(repoRepository, userRepository);
userViewModel.retry();
verifyNoMoreInteractions(repoRepository, userRepository);
Observer userObserver = mock(Observer.class);
userViewModel.getUser().observeForever(userObserver);
Observer repoObserver = mock(Observer.class);
userViewModel.getRepositories().observeForever(repoObserver);
verify(userRepository).loadUser("foo");
verify(repoRepository).loadRepos("foo");
reset(userRepository, repoRepository);
userViewModel.retry();
verify(userRepository).loadUser("foo");
verify(repoRepository).loadRepos("foo");
reset(userRepository, repoRepository);
userViewModel.getUser().removeObserver(userObserver);
userViewModel.getRepositories().removeObserver(repoObserver);
userViewModel.retry();
verifyNoMoreInteractions(userRepository, repoRepository);
}
@Test
public void nullUser() {
Observer<Resource<User>> observer = mock(Observer.class);
userViewModel.setLogin("foo");
userViewModel.setLogin(null);
userViewModel.getUser().observeForever(observer);
verify(observer).onChanged(null);
}
@Test
public void nullRepoList() {
Observer<Resource<List<Repo>>> observer = mock(Observer.class);
userViewModel.setLogin("foo");
userViewModel.setLogin(null);
userViewModel.getRepositories().observeForever(observer);
verify(observer).onChanged(null);
}
@Test
public void dontRefreshOnSameData() {
Observer<String> observer = mock(Observer.class);
userViewModel.login.observeForever(observer);
verifyNoMoreInteractions(observer);
userViewModel.setLogin("foo");
verify(observer).onChanged("foo");
reset(observer);
userViewModel.setLogin("foo");
verifyNoMoreInteractions(observer);
userViewModel.setLogin("bar");
verify(observer).onChanged("bar");
}
@Test
public void noRetryWithoutUser() {
userViewModel.retry();
verifyNoMoreInteractions(userRepository, repoRepository);
}
}
Repository Tests
Each Repository is tested using local unit tests with mock web service and mock database.
UserRepositoryTest.java
@RunWith(JUnit4.class)
public class UserRepositoryTest {
private UserDao userDao;
private GithubService githubService;
private UserRepository repo;
@Rule
public InstantTaskExecutorRule instantExecutorRule = new InstantTaskExecutorRule();
@Before
public void setup() {
userDao = mock(UserDao.class);
githubService = mock(GithubService.class);
repo = new UserRepository(new InstantAppExecutors(), userDao, githubService);
}
@Test
public void loadUser() {
repo.loadUser("abc");
verify(userDao).findByLogin("abc");
}
@Test
public void goToNetwork() {
MutableLiveData<User> dbData = new MutableLiveData<>();
when(userDao.findByLogin("foo")).thenReturn(dbData);
User user = TestUtil.createUser("foo");
LiveData<ApiResponse<User>> call = ApiUtil.successCall(user);
when(githubService.getUser("foo")).thenReturn(call);
Observer<Resource<User>> observer = mock(Observer.class);
repo.loadUser("foo").observeForever(observer);
verify(githubService, never()).getUser("foo");
MutableLiveData<User> updatedDbData = new MutableLiveData<>();
when(userDao.findByLogin("foo")).thenReturn(updatedDbData);
dbData.setValue(null);
verify(githubService).getUser("foo");
}
@Test
public void dontGoToNetwork() {
MutableLiveData<User> dbData = new MutableLiveData<>();
User user = TestUtil.createUser("foo");
dbData.setValue(user);
when(userDao.findByLogin("foo")).thenReturn(dbData);
Observer<Resource<User>> observer = mock(Observer.class);
repo.loadUser("foo").observeForever(observer);
verify(githubService, never()).getUser("foo");
verify(observer).onChanged(Resource.success(user));
}
}
Webservice Tests
The project uses MockWebServer project to test REST api interactions.
@RunWith(JUnit4.class)
public class GithubServiceTest {
@Rule
public InstantTaskExecutorRule instantExecutorRule = new InstantTaskExecutorRule();
private GithubService service;
private MockWebServer mockWebServer;
@Before
public void createService() throws IOException {
mockWebServer = new MockWebServer();
service = new Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(new LiveDataCallAdapterFactory())
.build()
.create(GithubService.class);
}
@After
public void stopService() throws IOException {
mockWebServer.shutdown();
}
@Test
public void getUser() throws IOException, InterruptedException {
enqueueResponse("user-yigit.json");
User yigit = getValue(service.getUser("yigit")).body;
RecordedRequest request = mockWebServer.takeRequest();
assertThat(request.getPath(), is("/users/yigit"));
assertThat(yigit, notNullValue());
assertThat(yigit.avatarUrl, is("https://avatars3.githubusercontent.com/u/89202?v=3"));
assertThat(yigit.company, is("Google"));
assertThat(yigit.blog, is("birbit.com"));
}
@Test
public void getRepos() throws IOException, InterruptedException {
enqueueResponse("repos-yigit.json");
// LiveData<ApiResponse<List<Repo>>> yigit = service.getRepos("yigit");
List<Repo> repos = getValue(service.getRepos("yigit")).body;
RecordedRequest request = mockWebServer.takeRequest();
assertThat(request.getPath(), is("/users/yigit/repos"));
assertThat(repos.size(), is(2));
Repo repo = repos.get(0);
assertThat(repo.fullName, is("yigit/AckMate"));
Repo.Owner owner = repo.owner;
assertThat(owner, notNullValue());
assertThat(owner.login, is("yigit"));
assertThat(owner.url, is("https://api.github.com/users/yigit"));
Repo repo2 = repos.get(1);
assertThat(repo2.fullName, is("yigit/android-architecture"));
}
@Test
public void getContributors() throws IOException, InterruptedException {
enqueueResponse("contributors.json");
List<Contributor> contributors = getValue(
service.getContributors("foo", "bar")).body;
assertThat(contributors.size(), is(3));
Contributor yigit = contributors.get(0);
assertThat(yigit.getLogin(), is("yigit"));
assertThat(yigit.getAvatarUrl(), is("https://avatars3.githubusercontent.com/u/89202?v=3"));
assertThat(yigit.getContributions(), is(291));
assertThat(contributors.get(1).getLogin(), is("guavabot"));
assertThat(contributors.get(2).getLogin(), is("coltin"));
}
@Test
public void search() throws IOException, InterruptedException {
String header = "<https://api.github.com/search/repositories?q=foo&page=2>; rel=\"next\","
+ " <https://api.github.com/search/repositories?q=foo&page=34>; rel=\"last\"";
Map<String, String> headers = new HashMap<>();
headers.put("link", header);
enqueueResponse("search.json", headers);
ApiResponse<RepoSearchResponse> response = getValue(
service.searchRepos("foo"));
assertThat(response, notNullValue());
assertThat(response.body.getTotal(), is(41));
assertThat(response.body.getItems().size(), is(30));
assertThat(response.links.get("next"),
is("https://api.github.com/search/repositories?q=foo&page=2"));
assertThat(response.getNextPage(), is(2));
}
private void enqueueResponse(String fileName) throws IOException {
enqueueResponse(fileName, Collections.emptyMap());
}
private void enqueueResponse(String fileName, Map<String, String> headers) throws IOException {
InputStream inputStream = getClass().getClassLoader()
.getResourceAsStream("api-response/" + fileName);
BufferedSource source = Okio.buffer(Okio.source(inputStream));
MockResponse mockResponse = new MockResponse();
for (Map.Entry<String, String> header : headers.entrySet()) {
mockResponse.addHeader(header.getKey(), header.getValue());
}
mockWebServer.enqueue(mockResponse
.setBody(source.readString(StandardCharsets.UTF_8)));
}
}