本文为菜鸟窝作者蒋志碧的连载。“从 0 开始开发一款直播 APP ”系列来聊聊时下最火的直播 APP,如何完整的实现一个类"腾讯直播"的商业化项目
视频地址:http://www.cniao5.com/course/10121
【从 0 开始开发一款直播 APP】5.1 MVP 完全解析 -- 实现直播登录
【从 0 开始开发一款直播 APP】5.2 MVP 之 Fragment 交互实现滑动导航
【从 0 开始开发一款直播 APP】5.3 MVC 完全解析 -- 实现直播登录
MVP 概述
在无任何架构模式下的开发时,Activity 和 Model 之间的关系太紧密,做了所有的操作,不易维护,扩展性差。如果所有逻辑都在 Activity 中实现,代码显得臃肿不易维护和修改。
MVP(Model-View-Presenter,模型-视图-表示器)模式则是由 IBM 开发出来的一个针对 C++ 和 Java 的编程模型,大概出现于 2000 年,是 MVC 模式的一个变种,主要用来隔离UI、UI 逻辑和业务逻辑、数据。MVP 模式中不容许 View 直接访问 Model,这是 MVP 和 MVC 之间最大的不同。View 中应该只有 UI 逻辑,捕捉用户输入以及视图的渲染。这样将其它复杂的逻辑抽离出来放到 Presenter 中去,这样就出现了MVP。简单的说,就是将 View 中的复杂工作抽取到 Presenter 中,降低了耦合度,便于维护和测试,也增强了复用性。
Presenter 是 Model 和 View 之间的桥梁,为了让结构变得更加简单,View 并不能直接对 Model 进行操作,这也是 MVP 与 MVC 最大的不同之处。
Model — 业务逻辑和数据模型。
Model 层主要负责
1、从网络,数据库,文件,传感器,第三方等数据源读写数据。
2、对外部的数据类型进行解析转换为 APP 内部数据交由上层处理。
3、对数据的临时存储,管理,协调上层数据请求。
View — View 的绘制和用户交互
View 层主要负责
1、提供 UI 交互
2、在 Presenter 的控制下修改 UI
3、将业务事件交由 Presenter 处理
注意:View 层不存储数据,不与 Model 层交互
Presenter — View 和 Model 间的交互
Presenter 层主要负责
1、作为 View 和 Model 之间的纽带,处理与用户交互的逻辑实现。
2、根据用户在视图中的行为更新模型的逻辑
3、负责从 View 视图中取得数据发送给模型
MVP 优点
1、降低耦合度,实现了Model和View真正的完全分离,可以修改 View 而不影响 Model
2、模块职责划分明显,层次清晰
3、隐藏数据
4、 Presenter 可以复用,一个 Presenter 可以用于多个 View,而不需要更改 Presenter的逻辑(当然是在 View 的改动不影响业务逻辑的前提下)
5、利于测试驱动开发。在使用 MVP 的项目中 Presenter 对 View 是通过接口进行,在对 Presenter 进行不依赖 UI 环境的单元测试的时候。可以通过 Mock 一个 View 对象,这个对象只需要实现了 View 的接口即可。然后依赖注入到 Presenter 中,单元测试的时候就可以完整的测试 Presenter 应用逻辑的正确性。
6、View可以进行组件化。在MVP当中,View不依赖Model。这样就可以让View从特定的业务场景中脱离出来,可以说View可以做到对业务完全无知。它只需要提供一系列接口提供给上层操作。这样就可以做到高度可复用的View组件。
7、提高代码灵活性
MVP 缺点
1、Presenter中除了应用逻辑以外,还有大量的 View -> Model,Model -> View 的手动同步逻辑,造成 Presenter 比较笨重,维护起来会比较困难。
2、由于对视图的渲染放在了 Presenter 中,所以视图和 Presenter 的交互会过于频繁。
3、如果 Presenter 过多地渲染了视图,往往会使得它与特定的视图的联系过于紧密。一旦视图需要变更,那么 Presenter 也需要变更了。
4、额外的代码复杂度及学习成本。
MVP 使用
MVP 的逻辑实现类图,根据 MVP 模型,定义 P 层和 V 层接口以及基本方法以及 M 层相关方法。
使用 MVP 大致要做以下步骤
1、创建 IPresenter 接口,把所有业务逻辑接口都定义在内,并创建它的实现类 PresenterImpl。Ipresenter 持有 IView 的引用,调用 IView 中的方法。
2、创建 IView接口,把所有视图逻辑的接口都定义在内,创建其实现类 Activity / Fragment。
3、IPresenter 间接持有 Model 的引用,但 Model 并不是必须有的,但是一定会有 Presenter 和 View。
4、在上图中可以看到,Activity 中包含了一个 IPresenter,而 PresenterImpl 里包含了一个 IView并依赖于 Model。Activity 只保留了对 IPresenter 的调用,其它工作全部由 PresenterImpl 实现。
MVP 实现登录
Presenter — LoginPresenter
1、定义 BasePresenter 接口,主要方法是 start() — presenter 开始处理数据,绑定 Presenter 。 finish() — 处理销毁工作,界面结束时调用,取消绑定 Presenter 的方法。
2、定义 ILoginPresenter 抽象类实现 BasePresenter 接口,定义登录所需逻辑处理方法,与 ILoginView 关联。
3、定义 LoginPresenter 继承 ILoginPresenter 抽象类,实现抽象方法以及处理逻辑。
View — LoginActivity
1、定义 BaseView 接口,将通用方法封装到里面。
2、定义 ILoginView 接口,定义登录需要的通用方法。
3、创建 LoginActivity 实现 ILoginView 接口,并与 LoginPresenter 关联。
Model — UserInfo
根据登录请求数据定义 Model 所需字段以及 set() 和 get() 方法,注意:Model 要实现序列化接口。
根据相关类图创建包和类。
对登录不了解的请查看 【从 0 开始开发一款直播 APP】4.4 网络封装之 OkHttp -- 网络请求实现直播登录
在后面的代码中会涉及到网络的状态判断,倒计时器的加载,弱引用,ACache 缓存等,有兴趣的可以看看。
【从 0 开始开发一款直播 APP】6 缓存 ACache 源码解析
【从 0 开始开发一款直播 APP】7 倒计时器 CountDownTimer 源码解析
【从 0 开始开发一款直播 APP】8 弱引用 WeakReference
【从 0 开始开发一款直播 APP】9 网络连接状态详解
Presenter 所有类实现
1、BasePresenter
public interface BasePresenter{
/**
* presenter 开始处理方法
*/
void start();
/**
* 处理一些销毁工作,在界面结束时候调用
*/
void finish();
}
2、ILoginPresenter
public abstract class ILoginPresenter implements BasePresenter {
protected BaseView mBaseView;
public ILoginPresenter(BaseView baseView) {
mBaseView = baseView;
}
/**
* 检查手机号验证码是否合法
* @param phone
* @param verifyCode
* @return
*/
public abstract boolean checkPhoneLogin(String phone, String verifyCode);
/**
* 检查用户名密码是否合法
* @param userName
* @param password
* @return
*/
public abstract boolean checkUserNameLogin(String userName, String password);
/**
* 手机号登录
* @param phone
* @param verifyCode
*/
public abstract void phoneLogin(String phone, String verifyCode);
/**
* 用户名登录
* @param userName
* @param password
*/
public abstract void userNameLogin(String userName, String password);
/**
* 发送验证码
* @param phoneNum
*/
public abstract void sendVerifyCode(String phoneNum);
}
3、LoginPresenter
实现 ILoginPresenter 接口及其方法的逻辑
public class LoginPresenter extends ILoginPresenter {
private ILoginView mLoginView;
public LoginPresenter(ILoginView loginView) {
super(loginView);
mLoginView = loginView;
}
@Override
public void start() {
}
@Override
public void finish() {
//与 View 解绑
if (mLoginView!=null){
mLoginView = null;
}
}
@Override
public boolean checkPhoneLogin(String phone, String verifyCode) {
//判断手机号是否合法
if (OtherUtils.isPhoneNumValid(phone)) {
//判断验证码是否正确
if (OtherUtils.isVerifyCodeValid(verifyCode)) {
//监测网络状态,包括网络类型(Wi-Fi,2G,3G,4G)以及网络是否连接正常,设置网络等
if (OtherUtils.checkNetWorkState(mLoginView.getContext())) {
return true;
} else {
mLoginView.showMsg("当前无网络连接!");
}
} else {
mLoginView.verifyCodeError("验证码错误!");
}
} else {
mLoginView.phoneError("手机格式错误!");
}
mLoginView.dismissLoading();
return false;
}
@Override
public boolean checkUserNameLogin(String userName, String password) {
//判断用户名是否合法
if (OtherUtils.isUsernameVaild(userName)) {
//判断密码是否合法
if (OtherUtils.isPasswordValid(password)) {
//监测网络状态,包括网络类型(Wi-Fi,2G,3G,4G)以及网络是否连接正常,设置网络等
if (OtherUtils.checkNetWorkState(mLoginView.getContext())) {
return true;
} else {
mLoginView.showMsg("当前无网络连接!");
}
} else {
mLoginView.passwordError("密码过短!");
}
} else {
mLoginView.usernameError("用户名不符合规范!");
}
//取消loading进度显示
mLoginView.dismissLoading();
return false;
}
@Override
public void phoneLogin(final String phone, final String verifyCode) {
//判断手机号和验证码是否正确
if (checkPhoneLogin(phone, verifyCode)) {
final PhoneLoginRequest request = new PhoneLoginRequest(RequestComm.loginPhone, phone, verifyCode);
AsyncHttp.instance().postJson(request, new AsyncHttp.IHttpListener() {
@Override
public void onStart(int requestId) {
//显示loading进度
mLoginView.showLoading();
}
@Override
public void onSuccess(int requestId, Response response) {
if (response.getStatus() == RequestComm.SUCCESS) {
ACache.get(mLoginView.getContext()).put(CacheConstants.LOGIN_PHONE, phone);
mLoginView.loginSuccess();
} else {
mLoginView.loginFailed(response.getStatus(), response.getMsg());
}
mLoginView.dismissLoading();
}
@Override
public void onFailure(int requestId, int httpStatus, Throwable error) {
mLoginView.verifyCodeFailed("网络异常");
mLoginView.dismissLoading();
}
});
}
}
@Override
public void userNameLogin(final String userName, final String password) {
//判断用户名是否合法
if (checkUserNameLogin(userName, password)) {
LoginRequest request = new LoginRequest(RequestComm.loginUsername, userName, password);
AsyncHttp.instance().postJson(request, new AsyncHttp.IHttpListener() {
@Override
public void onStart(int requestId) {
mLoginView.showLoading();
}
@Override
public void onSuccess(int requestId, Response response) {
if (response.getStatus() == RequestComm.SUCCESS) {
//请求数据成功
UserInfo info = (UserInfo) response.getData();
//将登陆数据存入缓存,这个在后面的文章会介绍
UserInfoCache.saveCache(mLoginView.getContext(), info);
ACache.get(mLoginView.getContext()).put(CacheConstants.LOGIN_USERNAME, userName);
ACache.get(mLoginView.getContext()).put(CacheConstants.LOGIN_PASSWORD, password);
mLoginView.loginSuccess();
} else {
mLoginView.loginFailed(response.getStatus(), response.getMsg());
mLoginView.dismissLoading();
}
}
@Override
public void onFailure(int requestId, int httpStatus, Throwable error) {
mLoginView.loginFailed(httpStatus, error.getMessage());
mLoginView.dismissLoading();
}
});
}
}
@Override
public void sendVerifyCode(String phoneNum) {
//判断手机号是否合法
if (OtherUtils.isPhoneNumValid(phoneNum)) {
//监测网络状态,包括网络类型(Wi-Fi,2G,3G,4G)以及网络是否连接正常,设置网络等
if (OtherUtils.checkNetWorkState(mLoginView.getContext())) {
VerifyCodeRequest request = new VerifyCodeRequest(RequestComm.verifyCodeRequestId, phoneNum);
AsyncHttp.instance().postJson(request, new AsyncHttp.IHttpListener() {
@Override
public void onStart(int requestId) {
mLoginView.showLoading();
}
@Override
public void onSuccess(int requestId, Response response) {
if (response.getStatus() == RequestComm.SUCCESS) {
UserInfo info = (UserInfo) response.getData();
if (null != mLoginView){
//倒计时器 60 秒发送验证码
mLoginView.verifyCodeSuccess(60,60);
}
}else {
mLoginView.verifyCodeFailed("获取后台验证码失败!");
}
mLoginView.dismissLoading();
}
@Override
public void onFailure(int requestId, int httpStatus, Throwable error) {
if (null != mLoginView){
mLoginView.verifyCodeFailed("获取后台验证码失败!");
}
mLoginView.dismissLoading();
}
});
} else {
mLoginView.showMsg("当前无网络连接!");
}
} else {
mLoginView.phoneError("手机号码不符合规范!");
}
}
}
View 所有类实现
1、BaseView
public interface BaseView {
/**
* 数据加载或耗时加载时界面显示
*/
void showLoading();
/**
* 数据加载或耗时加载完成时界面显示
*/
void dismissLoading();
/**
* 消息提示,如 Toast,Dialog等
*/
void showMsg(String msg);
void showMsg(int msgId);
/**
* 获取Context
* @return
*/
Context getContext();
}
2、ILoginView
在 LoginPresenter 中绑定了 ILoginView,在 LoginPresenter 实现逻辑时调用的时 ILoginView 中的方法。
public interface ILoginView extends BaseView {
/**
* 登录成功
*/
void loginSuccess();
/**
* 登录失败
* @param status
* @param msg
*/
void loginFailed(int status, String msg);
/**
* 用户名错误
* @param errorMsg
*/
void usernameError(String errorMsg);
/**
*手机号错误
* @param errorMsg
*/
void phoneError(String errorMsg);
/**
* 密码错误
* @param errorMsg
*/
void passwordError(String errorMsg);
/**
* 验证码错误
* @param errorMsg
*/
void verifyCodeError(String errorMsg);
/**
* 验证失败
* @param errorMsg
*/
void verifyCodeFailed(String errorMsg);
/**
* 验证成功
* @param reaskDuration
* @param expireDuration
*/
void verifyCodeSuccess(int reaskDuration, int expireDuration);
}
3、LoginActivity
之前的文章中有讲过用户登录实现,那时是硬实现,没有逻辑分层,可以结合前面的文章一起学习。
public class LoginActivity extends BaseActivity implements View.OnClickListener, ILoginView {
//共用控件
private ProgressBar progressBar;
private EditText etPassword;
private EditText etLogin;
private Button btnLogin;
private Button btnPhoneLogin;
private TextInputLayout tilLogin, tilPassword;
private Button btnRegister;
//手机验证登陆控件
private TextView tvVerifyCode;
private boolean isPhoneLogin = false;
private LoginPresenter mLoginPresenter;
@Override
protected void setActionBar() {
}
@Override
protected void setListener() {
}
@Override
protected void initData() {
etLogin.setText(ACache.get(this).getAsString(CacheConstants.LOGIN_USERNAME));
etPassword.setText(ACache.get(this).getAsString(CacheConstants.LOGIN_PASSWORD));
}
@Override
protected void initView() {
mLoginPresenter = new LoginPresenter(this);
etLogin = obtainView(R.id.et_username);
etPassword = obtainView(R.id.et_password);
btnRegister = obtainView(R.id.btn_register);
btnPhoneLogin = obtainView(R.id.btn_phone_login);
btnLogin = obtainView(R.id.btn_login);
progressBar = obtainView(R.id.progressbar);
tilLogin = obtainView(til_login);
tilPassword = obtainView(R.id.til_password);
tvVerifyCode = obtainView(R.id.btn_verify_code);
userNameLoginViewInit();
}
@Override
protected int getLayoutId() {
return R.layout.activity_login;
}
/**
* 用户名密码登录界面init
*/
public void userNameLoginViewInit() {
//用户名登录切换
userLoginTrans();
tvVerifyCode.setOnClickListener(this);
//注册
btnRegister.setOnClickListener(this);
//手机号登录
btnPhoneLogin.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
//手机号登录
phoneLoginViewinit();
}
});
//用户名登录
btnLogin.setOnClickListener(this);
}
public void phoneLoginViewinit() {
phoneLoginTrans();
btnPhoneLogin.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
//转换为用户名登录界面
userNameLoginViewInit();
}
});
btnLogin.setOnClickListener(this);
btnRegister.setOnClickListener(this);
tvVerifyCode.setOnClickListener(this);
}
private void phoneLoginTrans() {
isPhoneLogin = true;
tvVerifyCode.setVisibility(View.VISIBLE);
AlphaAnimation alphaAnimation = new AlphaAnimation(0.0f, 1.0f);
alphaAnimation.setDuration(250);
tvVerifyCode.setAnimation(alphaAnimation);
//设定点击优先级于最前(避免被EditText遮挡的情况)
tvVerifyCode.bringToFront();
//设置输入框输入类型为 手机号
etLogin.setInputType(EditorInfo.TYPE_CLASS_PHONE);
etLogin.setText("");
etPassword.setText("");
//手机号登录按钮文字改为 用户名登录
btnPhoneLogin.setText("用户名登录");
tilLogin.setHint("手机号");
tilPassword.setHint("密码");
}
private void userLoginTrans() {
isPhoneLogin = false;
tvVerifyCode.setVisibility(View.GONE);
AlphaAnimation alphaAnimation = new AlphaAnimation(1.0f, 0.0f);
alphaAnimation.setDuration(250);
tvVerifyCode.setAnimation(alphaAnimation);
etLogin.setInputType(EditorInfo.TYPE_CLASS_TEXT);
etLogin.setText("");
etPassword.setText("");
btnPhoneLogin.setText("手机号登录");
tilLogin.setHint("用户名");
tilPassword.setHint("密码");
}
/**
* 手机登录和用户名登录界面显示或隐藏
* @param active
*/
public void showOnLoading(boolean active) {
if (active) {
progressBar.setVisibility(View.VISIBLE);
btnLogin.setVisibility(View.INVISIBLE);
etLogin.setEnabled(false);
etPassword.setEnabled(false);
btnPhoneLogin.setClickable(false);
btnRegister.setTextColor(getResources().getColor(R.color.colorTransparentGray));
btnPhoneLogin.setTextColor(getResources().getColor(R.color.colorTransparentGray));
btnRegister.setClickable(false);
} else {
progressBar.setVisibility(View.GONE);
btnLogin.setVisibility(View.VISIBLE);
etLogin.setEnabled(true);
etPassword.setEnabled(true);
btnPhoneLogin.setClickable(true);
btnRegister.setClickable(true);
btnRegister.setTextColor(getResources().getColor(R.color.white));
btnPhoneLogin.setTextColor(getResources().getColor(R.color.white));
}
}
public boolean checkPhoneLogin(String phone, String verifyCode) {
if (OtherUtils.isPhoneNumValid(phone)) {
if (OtherUtils.isVerifyCodeValid(verifyCode)) {
if (OtherUtils.checkNetWorkState(this)) {
return true;
} else {
ToastUtils.showShort(this, "当前无网络连接");
}
} else {
ToastUtils.showShort(this, "验证码错误");
}
} else {
ToastUtils.showShort(this, "手机格式错误");
}
return false;
}
public boolean checkUserNameLogin(String userName, String password) {
if (OtherUtils.isUsernameVaild(userName)) {
if (OtherUtils.isPasswordValid(password)) {
if (OtherUtils.checkNetWorkState(this)) {
return true;
} else {
ToastUtils.showShort(this, "当前无网络连接");
}
} else {
ToastUtils.showShort(this, "密码过短");
}
} else {
ToastUtils.showShort(this, "用户名不符合规范");
}
return false;
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.btn_login:
if (isPhoneLogin) {
mLoginPresenter.phoneLogin(etLogin.getText().toString(), etPassword.getText().toString());
} else {
mLoginPresenter.userNameLogin(etLogin.getText().toString(), etPassword.getText().toString());
}
break;
case R.id.btn_verify_code:
mLoginPresenter.sendVerifyCode(etLogin.getText().toString());
break;
case R.id.btn_register:
break;
}
}
@Override
public void loginSuccess() {
dismissLoading();
invoke(this, MainActivity.class);
finish();
}
@Override
public void loginFailed(int status, String msg) {
dismissLoading();
showMsg("登录失败:" + msg);
}
@Override
public void usernameError(String errorMsg) {
etLogin.setError(errorMsg);
}
@Override
public void phoneError(String errorMsg) {
etLogin.setError(errorMsg);
}
@Override
public void passwordError(String errorMsg) {
etPassword.setError(errorMsg);
}
@Override
public void verifyCodeError(String errorMsg) {
showMsg(errorMsg);
}
@Override
public void verifyCodeFailed(String errorMsg) {
showMsg(errorMsg);
}
@Override
public void verifyCodeSuccess(int reaskDuration, int expireDuration) {
showMsg("注册短信下发,验证码 " + expireDuration / 60 + " 分钟内有效!");
OtherUtils.startTimer(new WeakReference<TextView>(tvVerifyCode), "验证码", reaskDuration, 1);
}
@Override
public void showLoading() {
showOnLoading(true);
}
@Override
public void dismissLoading() {
showOnLoading(false);
}
@Override
public void showMsg(String msg) {
showToast(msg);
}
@Override
public void showMsg(int msgId) {
showMsg(msgId);
}
@Override
public Context getContext() {
return this;
}
}
Model 的定义
1、UserInfo 要实现序列化接口
public class UserInfo extends IDontObfuscate{
private String nickname;
private int sex;
private String headPic;
private String sigId;
private String userId;
private String sdkAppId;
private String sdkAccountType;
private String token;
public UserInfo() {
}
public UserInfo(String nickname, int sex, String headPic, String userId) {
this.nickname = nickname;
this.sex = sex;
this.headPic = headPic;
this.userId = userId;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public int getSex() {
return sex;
}
public void setSex(int sex) {
this.sex = sex;
}
public String getHeadPic() {
return headPic;
}
public void setHeadPic(String headPic) {
this.headPic = headPic;
}
public String getSigId() {
return sigId;
}
public void setSigId(String sigId) {
this.sigId = sigId;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getSdkAppId() {
return sdkAppId;
}
public void setSdkAppId(String sdkAppId) {
this.sdkAppId = sdkAppId;
}
public String getSdkAccountType() {
return sdkAccountType;
}
public void setSdkAccountType(String sdkAccountType) {
this.sdkAccountType = sdkAccountType;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
}
2、UserInfoCache 也要实现序列化接口
账号字段在后面的文章介绍,这里直接贴出来。
public class UserInfoCache extends IDontObfuscate{
public static void saveCache(Context context, UserInfo info){
ACache.get(context).put("user_id",info.getUserId());
ACache.get(context).put("nickname",info.getNickname());
ACache.get(context).put("head_pic",info.getHeadPic());
ACache.get(context).put("sig_id",info.getSigId());
ACache.get(context).put("token",info.getToken());
ACache.get(context).put("sdk_app_id",info.getSdkAppId());
ACache.get(context).put("adk_account_type",info.getSdkAccountType());
ACache.get(context).put("sex",info.getSex());
if (info.getSdkAppId() != null && TextUtils.isDigitsOnly(info.getSdkAccountType())){
Constants.IMSDK_ACCOUNT_TYPE = Integer.parseInt(info.getSdkAccountType());
}
}
public static String getUserId(Context context){
return ACache.get(context).getAsString("user_id");
}
public static String getNickname(Context context){
return ACache.get(context).getAsString("nickname");
}
public static String getHeadPic(Context context){
return ACache.get(context).getAsString("head_pic");
}
public static String getSigId(Context context){
return ACache.get(context).getAsString("sig_id");
}
public static String getToken(Context context){
return ACache.get(context).getAsString("token");
}
public static String getSdkAccountType(Context context){
return ACache.get(context).getAsString("adk_account_type");
}
public static String getSdkAppId(Context context){
return ACache.get(context).getAsString("sex");
}
public static String getSex(Context context){
return ACache.get(context).getAsString("sdk_app_id");
}
public static void clearCache(Context context){
ACache.get(context).remove("user_id");
ACache.get(context).remove("nickname");
ACache.get(context).remove("head_pic");
ACache.get(context).remove("sig_id");
ACache.get(context).remove("token");
ACache.get(context).remove("adk_account_type");
ACache.get(context).remove("sdk_app_id");
ACache.get(context).remove("sex");
}
}
登录实现运行效果
参考:
http://www.jianshu.com/p/9a6845b26856
http://www.jianshu.com/p/f6252719b3af
140套Android优秀开源项目源码,领取地址:http://mp.weixin.qq.com/s/afPGHqfdiApALZqHsXbw-A
网友评论