目录:
一.Functionality: 项目需求介绍
二.Architecture: 结构介绍
三.代码解读
四.Test 解读: 此项目的 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个页面:
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(库). ①每个搜索结果保存在 RepoSearchResult 表中的数据库里, 其中 repository IDs 列表被非规范为single column(单个列).②真实的 RepoEntity实例存于 Repo 表中.
Each time a new page is fetched, the same RepoSearchResult record in the Database is updated with the new list of repository ids.
每次获取新页面时,RepoSearchResult表中相同的记录将使用新的 repository IDs列表进行更新。
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项目,这在移动设备上不能很好地运行。与其手写懒加载适配器,不如使用 Room。
①table RepoSearchResult:
② table Repo:
2. RepoFragment.java:
This fragment displays the details of a repository and its contributors.
此片段显示存储库及其贡献者的详细信息。
details:
3.UserFragment.java
This fragment displays a user and their repositories.
此片段显示用户及其存储库。
table User:
二.Architecture
1.The final architecture
最终架构, 使用 repository 层, 获取网络数据, 缓存网络数据到内存, 存储网络数据到数据库;
监听 LiveData 的变化, 自动判断 Activity/Fragment 的生命周期是 visible 还是 gone, 自动更新界面.
2. 各个 Module 介绍
①.Activity/Fragment:
The UI controller that displays the data in the ViewModel and reacts to user interactions.显示 ViewModel 中的数据并相应 UI 的UI 控制器
②.ViewModel :
The class that prepares the data for the UI.为 UI 准备数据的类
③.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(库)模块负责处理数据的操作.你可以把它们当作(持久化模型,Web服务,缓存等)不同的数据源之间的调停。
而关注点分离原则提供的最大好处就是可测性.
④.RoomDatabase
database 的arch 实现, 持久化数据层
@Entity
数据库对象实体类
@DAO
数据访问对象
⑤.Webservice
use the Retrofit library to access our backend
Activity/Fragment 或者 ViewModel 可以直接使用 webservice 获取数据, 但是违背了ViewModel 层的separation of concerns关注点分离原则.所以ViewModel 委托(delegate)这件事给 Repository Module.
⑥.DI
依赖注入. 管理组件之间的依赖关系,简单化.
⑦.对数据操作的辅助类
NetworkBoundResource.java可以被用于多个项目
观察数据库的决策树: 是否请求服务器(fetch data).
三.代码解读
Entity
我们使用@Entity注释数据库中的表
①.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 设计为 Owner 内部类, 后面我看为什么要这么设计?
// Owner包扩 owner_login 和 owner_url 2个字段
@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_")// 注意这句话包扩 owner_login 和 owner_url 2个字段
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 方法声明使用变量 和上面的做对比
构造方法中缺少 repoName 和 repoOwner, 我们注意找到代码中如何给这2个变量赋值?
@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.
请注意 MyDatabase 是 abstract 的, room 会自动提供它的具体的实现.
使用注解@Database生成库, entities 生成一个或多个表, 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 注释, 对应数据库的 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 层 属于对数据操作的封装层, 包括网络获取数据, 和数据库中的数据
①.UserRepository.java
注意 loadUser()方法中的 NetworkBoundResource类, 后面再说
/**
* 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 类
@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
注意构造器中的被注释的代码, 此部分是源码, 不使用lambda 之后是注释后上面的部分,
我们能更清楚的看清 addSource的第2个参数为 observer 中有 onChanged() 方法, onChanged()方法比较重要,看下面的源码注释,我们可以知道Called when the data is changed.. 此方法是通过我们注册观察这个数据, 期望它变化的时候告诉我们.
此辅助类,我们可以在多个地方被重用。
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() 对象");
this.appExecutors = appExecutors;
result.setValue(Resource.loading(null));
LiveData<ResultType> dbSource = loadFromDb();
//从此部分开始
result.addSource(dbSource, new Observer<ResultType>() {
@Override
public void onChanged(@Nullable ResultType data) {
result.removeSource(dbSource);
if (shouldFetch(data)) {
Logger.e("需要请求网络");
fetchFromNetwork(dbSource);
} else {
Logger.e("不需要请求网络");
result.addSource(dbSource, new Observer<ResultType>() {
@Override
public void onChanged(@Nullable ResultType newData) {
result.setValue(Resource.success(newData));
}
});
}
}
});
//此部分结束
// result.addSource(dbSource, data -> {
// result.removeSource(dbSource);
// if (shouldFetch(data)) {
// Logger.e("需要请求网络");
// fetchFromNetwork(dbSource);
// } else {
// Logger.e("不需要请求网络");
// 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
处理和网络交互的工具
/**
* 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"关键字,在 xml 和 java 之间的关系, 我们可以搜索"visibleGone"和"showHide"看看都在哪些地方调用了
/**
* 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"关键字,在 xml 和 java 之间的关系, 我们也可以搜索"imageUrl"和"bindImage"看看都在哪些地方调用了
/**
* 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.
该项目使用 Espresso 进行 UI 测试.因为每个 fragment 都受限于ViewModel, 所有每个 test 都会模拟相关的 ViewModel 进行 test.
@RunWith(AndroidJUnit4.class)
public class RepoFragmentTest {
@Before
public void init() {
repoFragment = RepoFragment.create("a", "b");
//此处就是使用 mock 进行的模拟
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)));
}
}