Android MVP架構(RxJava+SQLBrite+Retrofit+OkHttp+Glide)
每年都有新的框架出來,比起以往的開發方式更高效,更簡潔,唯一成本是學習與踩坑。框架能實現的東西,android原生知識點也是能實現的,但是前者更效率高,節省時間。使用框架,花費更少的時間,更少的力去實現一個需求,何樂而不為?
若是 , 不熟悉MVP專案架構,可以閱讀Android MVP。
這裡使用MVP專案架構外,還是用以下的框架:
Rxjava和RxAndroid :非同步響應式框架,替代非同步執行緒
SQLBrite :配合RxJava,替代ContentProvider+SQLIte+CursorLoader
Retrofit和OkHttp : 新的網路傳輸,替代HttpUrlConnection
Glide :載入圖片,視訊,GIF,可以替代傳統的載入影象資源方式
因此,這裡介紹Android MVP架構(RxJava+SQLBrite+Retrofit+OkHttp+Glide)來實現專案需求。
前期準備:
專案中,在Gradle中引入框架的配置如下:
dependencies {
compile fileTree(include: ['*.jar'], dir: 'libs')
testCompile 'junit:junit:4.12'
//android原生庫
compile 'com.android.support:appcompat-v7:25.3.1'
compile 'com.android.support:recyclerview-v7:25.3.1'
compile 'com.android.support:design:25.3.1'
//以下引入都是是採用的第三方庫
//非同步載入影象
compile 'com.github.bumptech.glide:glide:3.8.0'
//非同步執行緒操作
compile 'io.reactivex:rxjava:1.3.0'
//UI執行緒操作
compile 'io.reactivex:rxandroid:1.2.1'
//資料庫操作,但不是ORM
compile 'com.squareup.sqlbrite:sqlbrite:1.1.1'
//網路請求操作
compile 'com.squareup.retrofit2:retrofit:2.3.0'
compile 'com.squareup.okhttp3:okhttp:3.8.0'
//解析資料,Gson方式json
compile 'com.squareup.retrofit2:converter-gson:2.3.0'
//網路日誌輸出
compile 'com.squareup.okhttp3:logging-interceptor:3.8.0'
//結合RxJava使用
compile 'com.squareup.retrofit2:adapter-rxjava:2.3.0'
}
接下來,解下專案需求:
一個電影列表介面:
一個切換介面的抽屜選單:
一個收藏列表的介面:
根據上面的頁面,歸納出以下功能點:
- 電影列表
- 選擇多部電影進行收藏。
- 檢視被收藏的電影列表。
按模組劃分,可以分為電影列表模組,電影收藏模組。
根據功能點,開始編寫專案程式碼:
專案按(多個)業務模組,工具包模組,資料模組,UI模組分層如下:
1. 專案通用的BasePrester和BaseView介面:
專案中通用的BasePresenter介面,具備訂閱和取消訂閱的行為:
public interface BasePresenter {
/**
* 訂閱
*/
void subscribe();
/**
* 取消訂閱
*/
void unsubscribe();
}
專案中通用的BaseView介面,具備繫結Presenter的行為:
public interface BaseView<T> {
void setPresenter(T presenter);
}
2. 開始編寫Model中本地資料來源和遠端資料來源:
2.1 本地資料來源模組編寫:
資料庫中常量欄位:
public class MovieConstract implements BaseColumns {
/**
* 資料庫的資訊
*/
public static final String SQLITE_NAME="movie.db";
public static final int SQLITE_VERSON=1;
/**
* 表和欄位資訊
*/
public static final String TABLE_NAME_MOVI="movieData";
public static final String COLUMN_ID ="id";
public static final String COLUMN_YEAR="year";
public static final String COLUMN_TITLE="title";
public static final String COLUMN_IMAGES="image";
public static final String SQL_QUERY_MOVIE="select * from "+TABLE_NAME_MOVI;
}
SQLite資料庫配置:
public class MovieDataHelper extends SQLiteOpenHelper {
public static final String CREATE_TABLE_MOVIE = "create table " +
MovieConstract.TABLE_NAME_MOVI + "(" +
MovieConstract._ID + " integer primary key autoincrement," +
MovieConstract.COLUMN_ID + " text," +
MovieConstract.COLUMN_TITLE + " text," +
MovieConstract.COLUMN_YEAR + " text," +
MovieConstract.COLUMN_IMAGES + " text"
+ ")";
public MovieDataHelper(Context context) {
super(context, MovieConstract.SQLITE_NAME, null, MovieConstract.SQLITE_VERSON);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_TABLE_MOVIE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
}
}
使用SQLBrite框架,運算元據庫:不熟悉這個框架,可以先閱讀SQLBrite框架。
抽象出一個增,刪,查,改的行為的介面,在實現類中編寫SQLBrite操作:
public class MovieLocalSource implements LocalDataSource<MovieData> {
private BriteDatabase briteDatabase;
private static MovieLocalSource instance;
private Func1<Cursor,MovieData> cursorListFunc1;
private MovieLocalSource(Context context , SchedulerProvider schedulerProvider){
MovieDataHelper helper=new MovieDataHelper(context);
//配置SQLBrite框架
SqlBrite sqlBrite=new SqlBrite.Builder().build();
this.briteDatabase= sqlBrite.wrapDatabaseHelper(helper,schedulerProvider.io());
//查詢的資料返回Cusrsor將在這裡被回撥。
this.cursorListFunc1=new Func1<Cursor, MovieData>() {
@Override
public MovieData call(Cursor cursor) {
return TransformUtils.transformMovieData(cursor);
}
};
}
/**
* 獲取例項
* @param context
* @param schedulerProvider
* @return
*/
public static MovieLocalSource getInstance(Context context , SchedulerProvider schedulerProvider){
if(instance==null){
instance=new MovieLocalSource(context,schedulerProvider);
}
return instance;
}
/**
* 銷燬物件
*/
public static void destroyInstance(){
instance=null;
}
/**
*
*返回一個Observable物件,可以結合RxJava使用
*/
@Override
public Observable< List<MovieData>> queryAll() {
return queryAction(MovieConstract.SQL_QUERY_MOVIE,null);
}
@Override
public Observable< List<MovieData>> queryAction(String select, String[] selectArg) {
QueryObservable observable= selectArg==null?this.briteDatabase.createQuery(MovieConstract.TABLE_NAME_MOVI,select):this.briteDatabase.createQuery(MovieConstract.TABLE_NAME_MOVI,select,selectArg);
return observable.mapToList(this.cursorListFunc1);
}
@Override
public long insert(MovieData movieData) {
return this.briteDatabase.insert(MovieConstract.TABLE_NAME_MOVI,TransformUtils.transformMovieData(movieData));
}
/**
*批量插入
*/
@Override
public int bulkInsert(List<MovieData> list) {
int size=0;
//開啟事物
BriteDatabase.Transaction transaction= this.briteDatabase.newTransaction();
try {
for (MovieData movieData:list){
this.briteDatabase.insert(MovieConstract.TABLE_NAME_MOVI,TransformUtils.transformMovieData(movieData));
}
transaction.markSuccessful();
size=list.size();
}catch (Exception e){
size=0;
e.printStackTrace();
}finally {
transaction.end();
}
return size;
}
//專案中沒有刪,改操作,未編寫相關程式碼
@Override
public int update(MovieData movieData, String select, String[] selectArg) {
return 0;
}
@Override
public int delite(MovieData movieData, String select, String[] selectArg) {
return 0;
}
@Override
public void deliteAll() {
}
}
2.2 遠端資料來源模組編寫:
注意點:遠端資料可以分為文字資料和影象資料。
文字資料用Retrofit+OkHttp實現請求的響應:
採用OkHttp作為Retrofit的傳輸層 , 需 引入OkHttp
庫和OkHttp:logging-interceptor
庫:
public class OkHttpProvider {
/**
* 自定義配置OkHttpClient
* @return
*/
public static OkHttpClient createOkHttpClient(){
OkHttpClient.Builder builder=new OkHttpClient.Builder();
HttpLoggingInterceptor loggingInterceptor=new HttpLoggingInterceptor();
//列印一次請求的全部資訊
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
builder.addInterceptor(loggingInterceptor);
return builder.build();
}
}
標註點:採用豆瓣API中搜尋功能,這裡搜尋張藝謀的電影,返回前20個。
https://api.douban.com/v2/movie/search?q=張藝謀
先編寫Retrofit中請求中傳送的Body和Header,Respose的介面 :需要新增retrofit:adapter-rxjava
庫,實現介面卡功能。
public interface DouBanService{
//這裡返回一個Observable,用於RxJava結合使用
@GET("{path}")
Observable<MovieList> movieList(@Path("path") String path , @QueryMap Map<String,String> options);
}
配置Retrofit: 新增OkHttp作為傳輸層,RxJava介面卡,Gson解析的轉換器。
public class RemoteDataSource {
private final String BASE_URL = "https://api.douban.com/v2/movie/";
private final Retrofit retrofit;
private static RemoteDataSource instance;
private DouBanService douBanService;
private SchedulerProvider provider;
private RemoteDataSource() {
this.provider= SchedulerProvider.getInstacne();
OkHttpClient okHttpClient = OkHttpProvider.createOkHttpClient();
this.retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)//傳輸層
.addConverterFactory(GsonConverterFactory.create()) //Gson解析
.addCallAdapterFactory(RxJavaCallAdapterFactory.create()) //Rxjava介面卡
.build();
this.douBanService = this.retrofit.create(DouBanService.class);
}
/**
* 單例類物件
*
* @return
*/
public static synchronized RemoteDataSource getInstance() {
if (instance == null) {
instance = new RemoteDataSource();
}
return instance;
}
/*
* 在Presenter呼叫,在subscriber訂閱者中響應
*/
public Subscription movieList(Subscriber<List<Movie>> subscriber) {
String url = "search";
Map<String,String> map=new HashMap<>();
map.put("q","張藝謀");
Observable<MovieList> observable = this.douBanService.movieList(url, map);
//floatMap操作符轉換
Observable<List<Movie>> observable1= observable.flatMap(new Func1<MovieList, Observable<List<Movie>>>() {
@Override
public Observable<List<Movie>> call(MovieList movieList) {
return Observable.just(movieList.getSubjects());
}
});
return observable1.subscribeOn(provider.io()).unsubscribeOn(provider.io()).observeOn(provider.ui()).subscribe(subscriber);
}
}
接下來,編寫影象的配置:
影象資料用Glide實現載入:
建立ImageLoader類,封裝呼叫方法,URI的自定義拼接,實現按影象尺寸來獲取最原始資料:來源:參考於Google IO APP中處理方式。
public class ImageLoader {
private final CenterCrop mCenterCrop;
private final BitmapTypeRequest<String> glideModelRequest;
private static final ModelCache<String ,GlideUrl> urlCache=new ModelCache<>(150);
private int mPlaceHolderResId=-1;
public ImageLoader(Context context){
/**
* 轉換網址的操作類
*/
CustomVariableWithImageLoader variableWithImageLoader=new CustomVariableWithImageLoader(context);
/*
* 總是將資源載入成一個Bitmap
*/
this.glideModelRequest= Glide.with(context).using(variableWithImageLoader).from(String.class).asBitmap();
BitmapPool bitmapPool=Glide.get(context).getBitmapPool();
this.mCenterCrop=new CenterCrop(bitmapPool);
}
/**
* 設定一個預設的 placehodler drawable
* @param context
* @param placeholdrResId
*/
public ImageLoader(Context context,int placeholdrResId){
this(context);
this.mPlaceHolderResId=placeholdrResId;
}
public void loadImage(String url, ImageView imageView){
loadImage(url,imageView,null);
}
public void loadImage(String url, ImageView imageView, RequestListener<String,Bitmap> requestListener){
loadImage(url,imageView,requestListener,null,false);
}
public void loadImage(String url, ImageView imageView, RequestListener<String,Bitmap> requestListener, Drawable placeholderOverride, boolean crop){
BitmapRequestBuilder request=beginImageLoad(url,requestListener,crop);
if(placeholderOverride!=null){
request.placeholder(placeholderOverride);
}else if(mPlaceHolderResId!=-1){
request.placeholder(mPlaceHolderResId);
}
request.into(imageView);
}
public BitmapRequestBuilder beginImageLoad(String url, RequestListener<String,Bitmap> requestListener,boolean crop){
return crop==true?this.glideModelRequest.load(url).listener(requestListener).transform(this.mCenterCrop):this.glideModelRequest.load(url).listener(requestListener);
}
/**
* 載入Resouces下圖片資源.
* @param context
* @param drawableResId
* @param imageView
*/
public void loadImage(Context context, int drawableResId, ImageView imageView){
Glide.with(context).load(drawableResId).into(imageView);
}
private static class CustomVariableWithImageLoader extends BaseGlideUrlLoader<String>{
/**
* 解析格式
*/
private static final Pattern PATTERN = Pattern.compile("__w-((?:-?\\d+)+)__");
public CustomVariableWithImageLoader(Context context) {
super(context,urlCache);
}
@Override
protected String getUrl(String model, int width, int height) {
Matcher matcher=PATTERN.matcher(model);
int bestBucket=0;
if(matcher.find()){
String[] found=matcher.group(1).split("-");
for (String bucketStr : found) {
bestBucket = Integer.parseInt(bucketStr);
if (bestBucket >= width) {
// the best bucket is the first immediately bigger than the requested width
break;
}
}
if (bestBucket > 0) {
model =matcher.replaceFirst("w"+bestBucket);
}
}
return model;
}
}
}
3. 根據模組業務編寫View和Presenter及它的實現類:
這裡,列舉:電影列表介面的模組
View告訴Presenter要載入資料,Presenter要獲取遠端資料來源,然後回撥的響應資料更新到UI上.
View告訴Presenter要收藏的電影,Presenter將收藏資料傳遞給本地資料來源,進行儲存,最後Presenter將儲存結果更新到UI上。
根據以上的View與Presenter互動,在一個合同介面中抽象出具體行為的View和Presenter。
public interface MovieListConstract {
interface Presenter extends BasePresenter {
/**
* 收藏的資料
*/
void collectionMovie(List<Movie> list);
}
interface View extends BaseView<Presenter> {
/**
* 載入從資料來源中獲取的資料
*/
void loadMovieList(List<Movie> list);
//最新結果響應在UI上
void showToast(String s);
}
}
在Fragment中實現View介面,實現具體操作:
public class MovieListFragment extends Fragment implements MovieListConstract.View, View.OnClickListener, SwipeRefreshLayout.OnRefreshListener {
private View rootView;
private RecyclerView recyclerView;
private MovieListAdapter adapter;
private ScrollChildSwipeRefreshLayout swipeRefreshLayout;
public static final String TAG = MovieListFragment.class.getSimpleName();
public static MovieListFragment newInstance() {
return new MovieListFragment();
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
this.rootView = inflater.inflate(R.layout.fragment_movielist, container, false);
return this.rootView;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
// .......省略部分程式碼
this.presenter.subscribe();
}
// .......省略部分程式碼
@Override
public void onPause() {
super.onPause();
//解除訂閱
this.presenter.unsubscribe();
}
private MovieListConstract.Presenter presenter;
@Override
public void setPresenter(MovieListConstract.Presenter presenter) {
this.presenter = presenter;
}
@Override
public void showToast(String s) {
Toast.makeText(BaseApplication.getAppContext(), s, Toast.LENGTH_SHORT).show();
}
@Override
public void loadMovieList(List<Movie> list) {
this.adapter.upData(list);
this.setLoadingIndicator(false);
}
@Override
public void onClick(View v) {
if (this.adapter.getMoviesCollecion().size() == 0) {
showToast("請勾選中電影");
} else {
this.presenter.collectionMovie(this.adapter.getMoviesCollecion());
}
}
}
在一個Pesenter介面的實現類中:使用RxJava和RxAndroid實現非同步運算元據,和UI更新。
public class MovieListPresenter implements MovieListConstract.Presenter {
private CompositeSubscription compositeSubscription;
private MovieListConstract.View view;
private LocalDataSource<MovieData> dataLocalDataSource;
private RemoteDataSource remoteDataSource;
private SchedulerProvider schedulerProvider;
public MovieListPresenter(MovieListConstract.View view, LocalDataSource<MovieData> dataLocalDataSource, RemoteDataSource remoteDataSource) {
this.compositeSubscription = new CompositeSubscription();
this.dataLocalDataSource = dataLocalDataSource;
this.remoteDataSource = remoteDataSource;
this.schedulerProvider = SchedulerProvider.getInstacne();
this.view = view;
this.view.setPresenter(this);
}
@Override
public void collectionMovie(final List<Movie> list) {
Subscription subscription = Observable.create(new Observable.OnSubscribe<Boolean>() {
@Override
public void call(Subscriber<? super Boolean> subscriber) {
List<MovieData> movieDataList = new ArrayList<>();
for (Movie movie : list) {
movieDataList.add(TransformUtils.transformMovies(movie));
}
int size = dataLocalDataSource.bulkInsert(movieDataList);
subscriber.onNext(size > 0 ? true : false);
subscriber.onCompleted();
}
}).subscribeOn(schedulerProvider.io()).
observeOn(schedulerProvider.ui()).
subscribe(new Observer<Boolean>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
if (isViewBind()) {
String msg="收藏失敗";
view.showToast(msg);
}
}
@Override
public void onNext(Boolean aBoolean) {
if (isViewBind()) {
String msg=aBoolean==false?"收藏失敗":"收藏成功,可在收藏頁面檢視";
view.showToast(msg);
}
}
});
this.compositeSubscription.add(subscription);
}
@Override
public void subscribe() {
loadRemoteTask();
}
/**
* 開始載入遠端的資料
*/
private void loadRemoteTask() {
Subscription subscription = remoteDataSource.movieList(new Subscriber<List<Movie>>() {
@Override
public void onCompleted() {
if (isViewBind()) {
view.showToast("獲取列表成功");
}
}
@Override
public void onError(Throwable e) {
if (isViewBind()) {
view.showToast("載入失敗");
}
}
@Override
public void onNext(List<Movie> list) {
view.loadMovieList(list);
}
});
this.compositeSubscription.add(subscription);
}
@Override
public void unsubscribe() {
this.compositeSubscription.clear();
}
/**
* 檢查View是否被繫結
*
* @return
*/
private boolean isViewBind() {
return this.view == null ? false : true;
}
}
最後Activity中,建立Presenter和View:
private MovieListConstract.Presenter presenter;
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_movielist);
.....
MovieListFragment fragment=null;
if(savedInstanceState!=null){
fragment=(MovieListFragment) getSupportFragmentManager().findFragmentByTag(MovieListFragment.TAG);
}else{
fragment=MovieListFragment.newInstance();
getSupportFragmentManager().beginTransaction().add(R.id.movielist_content_layout,fragment,MovieListFragment.TAG).commit();
}
this.presenter=new MovieListPresenter(fragment,MovieLocalSource.getInstance(BaseApplication.getAppContext(), SchedulerProvider.getInstacne()), RemoteDataSource.getInstance());
}
電影收藏的業務也是類似,只要要抽出View與Presenter的互動行為,剩下的便是呼叫資料來源。
最好,可以結合Android MVP架構來加深理解。
4. 一些工具類,和UI類配置:
轉換工具類:轉換常用物件
public class TransformUtils {
/**
* 將Cursor 生成MovieData物件
* @param cursor
* @return
*/
public static MovieData transformMovieData(Cursor cursor) {
MovieData movieData = new MovieData();
movieData.setId(cursor.getString(cursor.getColumnIndex(MovieConstract.COLUMN_ID)));
movieData.setTitle(cursor.getString(cursor.getColumnIndex(MovieConstract.COLUMN_TITLE)));
movieData.setYear(cursor.getString(cursor.getColumnIndex(MovieConstract.COLUMN_YEAR)));
movieData.setImages(cursor.getString(cursor.getColumnIndex(MovieConstract.COLUMN_IMAGES)));
return movieData;
}
public static MovieData transformMovies(Movie movie){
MovieData movieData=new MovieData();
movieData.setId(movie.getId());
movieData.setYear(movie.getYear());
movieData.setTitle(movie.getTitle());
movieData.setImages(movie.getImages().getLarge());
return movieData;
}
/**
* 將Movie生成Cursor.
* @param movie
* @return
*/
public static ContentValues transformMovieData(MovieData movie){
ContentValues contentValues=new ContentValues();
contentValues.put(MovieConstract.COLUMN_ID,movie.getId());
contentValues.put(MovieConstract.COLUMN_TITLE,movie.getTitle());
contentValues.put(MovieConstract.COLUMN_YEAR,movie.getYear());
contentValues.put(MovieConstract.COLUMN_IMAGES,movie.getImages());
return contentValues;
}
/**
* 將Movie生成Cursor.
* @param movie
* @return
*/
public static ContentValues transformMovie(Movie movie){
ContentValues contentValues=new ContentValues();
contentValues.put(MovieConstract.COLUMN_ID,movie.getId());
contentValues.put(MovieConstract.COLUMN_TITLE,movie.getTitle());
contentValues.put(MovieConstract.COLUMN_YEAR,movie.getYear());
contentValues.put(MovieConstract.COLUMN_IMAGES,movie.getImages().getLarge());
return contentValues;
}
}
UI自定義類:SwipeRefreshLayout 支援非直接子類滾動檢視
ublic class ScrollChildSwipeRefreshLayout extends SwipeRefreshLayout {
private View scrollUpChild;
public ScrollChildSwipeRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
/**
* 設定在哪個view中觸發重新整理。
* @param view
*/
public void setScrollUpChild(View view){
this.scrollUpChild=view;
}
/**
*ViewCompat..canScrollVertically():用於檢查view是否可以在某個方向上垂直滑動
* @return
*/
@Override
public boolean canChildScrollUp() {
if(scrollUpChild!=null){
return ViewCompat.canScrollVertically(scrollUpChild,-1);
}
return super.canChildScrollUp();
}
}
5. 專案執行效果如下:
本專案的程式碼: https://github.com/13767004362/MVPPractice
資源參考:
Android MVP架構:http://blog.csdn.net/hexingen/article/details/72058057
Android MVP架構(Volley+CursorLoader+ContentProvider): http://blog.csdn.net/hexingen/article/details/72082419
相關文章
- Android MVP 架構AndroidMVP架構
- Android-MVP架構AndroidMVP架構
- MVP:有呼吸的Android架構MVPAndroid架構
- Android架構系列-MVP架構的實際應用Android架構MVP
- Android 架構選型 (MVP+DataBinding)Android架構MVP
- Android應用架構之MVP實現Android應用架構MVP
- Android架構系列-基於MVP建立適合自己的架構Android架構MVP
- Android MVP架構改造~如何重用頂層業務AndroidMVP架構
- 談談 Android MVP 架構 | 掘金技術徵文AndroidMVP架構
- Android開發中的MVP架構詳解AndroidMVP架構
- Android 架構設計:MVC、MVP、MVVM和元件化Android架構MVCMVPMVVM元件化
- MVP應用架構模式MVP應用架構模式
- MVP架構設計 初探MVP架構
- android-MVP架構中Presenter的單元測試AndroidMVP架構
- Android MVP架構(Volley+CursorLoader+ContentProvider)AndroidMVP架構IDE
- iOS開發-MVP架構模式iOSMVP架構模式
- iOS 架構模式–解密 MVC,MVP,MVVM以及VIPER架構iOS架構模式解密MVCMVPMVVM
- Android MVP架構從入門到精通-真槍實彈AndroidMVP架構
- Android從零開始(第三篇)MVP架構搭建AndroidMVP架構
- Kotlin如何實現MVP架構KotlinMVP架構
- MVC、MVP、MVVM,談談我對Android應用架構的理解MVCMVPMVVMAndroid應用架構
- 【Android架構】基於MVP模式的Retrofit2+RXjava封裝(一)Android架構MVP模式RxJava封裝
- MVP那些事兒 (2) 初探MVC架構MVPMVC架構
- 【Android】Dagger2實現更為規範化的MVP架構AndroidMVP架構
- iOS MVC、MVVM、MVP架構模式淺淺析iOSMVCMVVMMVP架構模式
- iOS架構淺談從 MVC、MVP 到 MVVMiOS架構MVCMVPMVVM
- 看完不會寫MVP架構我跪搓板MVP架構
- 架構設計的歷史·MVC·MVP·MVVM架構MVCMVPMVVM
- 開源專案Philm的MVP架構分析MVP架構
- MVP+Dagger2設計,MVP架構模式實現新思路 (Demo)MVP架構模式
- 【Android架構】基於MVP模式的Retrofit2+RXjava封裝之多Url(七)Android架構MVP模式RxJava封裝
- 設計Android應用程式架構的基本指南:MVP:第2部分Android架構MVP
- 帶你動手實現 MVP+Clean架構!MVP架構
- [譯]iOS架構模式——解密MVC、MVP、MVVM和VIPERiOS架構模式解密MVCMVPMVVM
- 轉享:表現層架構模式比較:MVP(SC),MVP(PV),PM,MVVM 和 MVC架構模式MVPMVVMMVC
- iOS架構設計:揭祕MVC, MVP, MVVM以及VIPERiOS架構MVCMVPMVVM
- MVP架構由淺入深篇一(基礎版)MVP架構
- 死磕安卓前序:MVP架構探究之旅—基礎篇安卓MVP架構