GithubBrowserSample項(xiàng)目學(xué)習(xí)及 Architecture room 理解

目錄:
一.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。

Screenshot_1499306923.png

①table RepoSearchResult:


image.png

② table Repo:


image.png

2. RepoFragment.java:

This fragment displays the details of a repository and its contributors.
此片段顯示存儲(chǔ)庫(kù)及其貢獻(xiàn)者的詳細(xì)信息。

Screenshot_1499307776.png

details:


image.png

3.UserFragment.java

This fragment displays a user and their repositories.
此片段顯示用戶及其存儲(chǔ)庫(kù)。

Screenshot_1499308040.png

table User:


image.png

二.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)更新界面.


final-architecture.png

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).

image.png

三.代碼解讀

Entity

我們使用@Entity注釋數(shù)據(jù)庫(kù)中的表

①.UserEntity.java

注意: 變量使用的 public 減少代碼而不是使用 private + get() set() 方法


table_user.png
@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è)字段

table_repo.png
@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

image.png
@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è)變量賦值?

image.png
@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

image.png
@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

image.png
@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.

1.gif
@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)));
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市拨与,隨后出現(xiàn)的幾起案子稻据,更是在濱河造成了極大的恐慌,老刑警劉巖买喧,帶你破解...
    沈念sama閱讀 211,194評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件棺牧,死亡現(xiàn)場(chǎng)離奇詭異旨巷,居然都是意外死亡师郑,警方通過(guò)查閱死者的電腦和手機(jī)消别,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,058評(píng)論 2 385
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)低淡,“玉大人荚斯,你說(shuō)我怎么就攤上這事〔榕疲” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 156,780評(píng)論 0 346
  • 文/不壞的土叔 我叫張陵滥壕,是天一觀的道長(zhǎng)纸颜。 經(jīng)常有香客問(wèn)我,道長(zhǎng)绎橘,這世上最難降的妖魔是什么胁孙? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 56,388評(píng)論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮称鳞,結(jié)果婚禮上涮较,老公的妹妹穿的比我還像新娘。我一直安慰自己冈止,他們只是感情好狂票,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,430評(píng)論 5 384
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著熙暴,像睡著了一般闺属。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上周霉,一...
    開(kāi)封第一講書(shū)人閱讀 49,764評(píng)論 1 290
  • 那天掂器,我揣著相機(jī)與錄音,去河邊找鬼俱箱。 笑死国瓮,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播乃摹,決...
    沈念sama閱讀 38,907評(píng)論 3 406
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼禁漓,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了峡懈?” 一聲冷哼從身側(cè)響起璃饱,我...
    開(kāi)封第一講書(shū)人閱讀 37,679評(píng)論 0 266
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎肪康,沒(méi)想到半個(gè)月后荚恶,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,122評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡磷支,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,459評(píng)論 2 325
  • 正文 我和宋清朗相戀三年谒撼,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片雾狈。...
    茶點(diǎn)故事閱讀 38,605評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡廓潜,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出善榛,到底是詐尸還是另有隱情辩蛋,我是刑警寧澤,帶...
    沈念sama閱讀 34,270評(píng)論 4 329
  • 正文 年R本政府宣布移盆,位于F島的核電站悼院,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏咒循。R本人自食惡果不足惜据途,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,867評(píng)論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望叙甸。 院中可真熱鬧颖医,春花似錦、人聲如沸裆蒸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,734評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)光戈。三九已至哪痰,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間久妆,已是汗流浹背晌杰。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,961評(píng)論 1 265
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留筷弦,地道東北人肋演。 一個(gè)月前我還...
    沈念sama閱讀 46,297評(píng)論 2 360
  • 正文 我出身青樓抑诸,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親爹殊。 傳聞我的和親對(duì)象是個(gè)殘疾皇子蜕乡,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,472評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容