Android MVP架构(Volley+CursorLoader+ContentProvider)
来源:互联网 发布:淘宝售假违规申诉凭证 编辑:程序博客网 时间:2024/06/06 19:46
若是,不熟悉MVP架构的,可以先阅读,Android MVP架构。
本篇,介绍Android MVP架构(Volley+CursorLoader+ContentProvider)来实现需求。
项目结构,分析图如下:
除开MVP架构外,还具备以下几种主要知识点:
- 数据库:ContentProvider+CursorLoader+SQLite实现数据实时刷新
- 网络通讯:Volley的几种请求
- 网络图片:Volley中的ImageLoader
- 数据解析:Gson库
- MaterialDesign设计库
采用以上anroid程序员必备技术,上手容易,不需要花费太多精力,去学习其他的第三方类库。
当然,也可以采用RxJava+SQLBrite+Glide+OkHttp+Retrofit
等第三方热门框架来实现Android MVP架构。具体如何实现,将由下篇博客介绍。
项目的效果图和需求:
一个电影列表界面:
一个切换界面的抽屉菜单:
一个收藏列表的界面:
根据上面的页面,归纳出以下功能点:
- 电影列表
- 选择多部电影进行收藏。
- 查看被收藏的电影列表。
按模块划分,可以分为电影列表模块,电影收藏模块。
接着,按上面分析,进行编写代码:
前期准备,项目的gradle中类库引用如下:
dependencies { compile fileTree(include: ['*.jar'], dir: 'libs') testCompile 'junit:junit:4.12' compile 'com.android.support:appcompat-v7:25.3.1' compile 'com.android.support:design:25.3.1' compile 'com.android.volley:volley:1.0.0' compile 'com.google.code.gson:gson:2.8.0' compile 'com.android.support:recyclerview-v7:25.3.1'}
1. 项目通用的BasePrester和BaseView接口:
BasePresenter接口用于一个开始加载资源的方法和解除对View对象引用的方法:
public interface BasePresenter { /** * 开启任务 */ void start(); /** * 解除对View的引用 */ void unbindView();}
BaseView接口,拥有一个设置Presenter对象的方法:
public interface BaseView<T> { /** * 设置Presenter */ void setPresenter(T t);}
2. Modle模块编写
Model模块分为本地数据和网络远程数据。
1. 本地数据源:
根据上面展示的收藏电影列表界面,来建立以下数据库中表及其字段。
将表中字段和表的Uri存放在一个BaseColumns实现类中:
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"; /** * 内容提供者的authority */ public static final String AUTHORITY="com.xingen.mvppractice.data.source.local.MovieDataProvider"; public static final String SCHEME="content"; private static final Uri CONTENT_URI=Uri.parse(SCHEME+"://"+AUTHORITY); public static final Uri MOVIEDATA_URI=Uri.withAppendedPath(CONTENT_URI,TABLE_NAME_MOVI);}
数据库建立如下:
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) { }}
如何自定义的ContentProvider,请阅读ContetProvider+SQLite+CursorLoader实现数据库观察者模式。也可以阅读本项目中代码。
接下来,编写增,删,查,改的操作。创建数据库的表中数对应的操作类的超级接口:
public interface LocalDataSource<T> { /** * 获取全部 * @return */ List<T> queryAll(); /** * 指定条件下的查询 * @param select * @param selectArg * @return */ List<T> queryAction(String select,String[] selectArg); /** * 新增 * @param t * @return */ long insert(T t); /** * 批量插入 * @param list * @return */ int bulkInsert( List<T> list); /** * 更新 * @param t * @param select * @param selectArg * @return */ int update(T t,String select,String[] selectArg); /** * 指定条件的删除 * @param t * @param select * @param selectArg * @return */ int delite(T t,String select,String[] selectArg); /** * 删除全部 */ void deliteAll();}
最后,编写接口的实现类,即每个表的各种对应的操作类,采用ContentResolver对象来完成:
public class MovieLocalSource implements LocalDataSource<MovieData> { private ContentResolver contentResolver; public MovieLocalSource(ContentResolver contentResolver){ this.contentResolver=contentResolver; } @Override public List<MovieData> queryAll() { //查询工作,由CursorLoader已经完成 return null; } @Override public List<MovieData> queryAction(String select, String[] selectArg) { //查询工作,由CursorLoader已经完成 return null; } @Override public long insert(MovieData movieData) { ContentValues contentValues= TransformUtils.transformMovieData(movieData); Uri uri=this.contentResolver.insert(MovieConstract.MOVIEDATA_URI,contentValues); if(uri!=null){ String s= uri.toString(); long rowId=Long.valueOf(s.substring(s.lastIndexOf("/",s.length()))); return rowId; } return -1; } @Override public int bulkInsert(List<MovieData> list) { ContentValues[] contentValuesArray=new ContentValues[list.size()]; for (int i=0;i<list.size();++i){ contentValuesArray[i]= TransformUtils.transformMovieData(list.get(i)); } return this.contentResolver.bulkInsert(MovieConstract.MOVIEDATA_URI,contentValuesArray); } @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() { }}
更多以上SQLite,自定义ContentProvider如何配置,ContetResolver使用。
请阅读前面教程之[ContetProvider+SQLite+CursorLoader实现数据库观察者模式] (http://blog.csdn.net/hexingen/article/details/71597884)。
本地数据源包结构图如下:
2. 网络数据源:
后台服务器返回的数据结构有多种(String,xml,json),这里统一返回String类型的数据,然后各种对应类型再解析。考虑到现在主流的Post传递的数据类型为json。因此,重写StringRequest,使其支持Json数据类型的Body:
public class StringBodyRequest extends StringRequest { /** Charset for request. */ private static final String PROTOCOL_CHARSET = "utf-8"; /** Content type for request. */ private static final String PROTOCOL_CONTENT_TYPE = String.format("application/json; charset=%s", PROTOCOL_CHARSET); /** * 自定义header: */ private Map<String, String> headers; /** * post传递的参数 */ private final String mRequestBody; /** * 请求结果的监听器 */ private RequestResultListener resultListener; /** * 请求的id */ private int requestId; public StringBodyRequest( String url, int requestId,StringBodyRequest.RequestResultListener resultListener) { this(Method.GET,url,null,requestId,resultListener); } public StringBodyRequest(int method, String url, JSONObject jsonObject,int requestId,StringBodyRequest.RequestResultListener resultListener) { super(method, url,null,null); this.headers = new HashMap<>(); this.mRequestBody=(jsonObject==null?null:jsonObject.toString()); this.resultListener=resultListener; this.requestId=requestId; } /** * 重写getHeaders(),添加自定义的header * * @return * @throws AuthFailureError */ @Override public Map<String, String> getHeaders() throws AuthFailureError { return headers; } /** * 设置请求的header * "Charset", "UTF-8"://编码格式:utf-8 * "Cookie", coockie:////设置coockie * @param * @return */ public Map<String, String> setHeader(String key, String content) { if(!TextUtils.isEmpty(key)&&!TextUtils.isEmpty(content)){ headers.put(key, content); } return headers; } /** * 重写Content-Type:设置为json */ @Override public String getBodyContentType() { return PROTOCOL_CONTENT_TYPE; } /** * post参数类型 */ @Override public String getPostBodyContentType() { return getBodyContentType(); } /** * post参数 */ @Override public byte[] getPostBody() throws AuthFailureError { return getBody(); } /** * 将string编码成byte * @return * @throws AuthFailureError */ @Override public byte[] getBody() throws AuthFailureError { try { return mRequestBody == null ? null : mRequestBody.getBytes(PROTOCOL_CHARSET); } catch (Exception e) { return null; } } /** * 重写传递异常的回调 * @param error */ @Override public void deliverError(VolleyError error) { this.resultListener.failure(requestId,error); } /** * 重写传递结果的回调 * @param response */ @Override protected void deliverResponse(String response) { this.resultListener.success(requestId,response); } /** * 自定义请求结果和异常的回调接口 */ public interface RequestResultListener{ void success(int requestId,String response); void failure(int reqestId,VolleyError error); }}
ImageLoader中Lrucache配置和Volley操作类的配置省略不贴出来。在项目中有详细介绍,请自行阅读。如何自定义请求,阅读 Volley源码分析之自定义GsonRequest教程。
考虑到请求有几种情况,有请求方式,header,body差异。因此,远程操作类接口封装以下几种方法:
public interface RemoteDataSource { void excuteRequest(String url, int requestId, String tag, StringBodyRequest.RequestResultListener resultListener); void excuteRequest(String url, Map<String, String> headrMap, int requestId, String tag, StringBodyRequest.RequestResultListener resultListener); void excuteRequest(int method, String url, JSONObject jsonObject, Map<String, String> headrMap, int requestId, String tag, StringBodyRequest.RequestResultListener resultListener); void excuteRequest(int method, String url, JSONObject jsonObject, int requestId, String tag, StringBodyRequest.RequestResultListener resultListener);}
远程网络的操作类的实现具体如下:
public class RemoteDataSourceImp implements RemoteDataSource { /** * 静态方式构建 * @return */ public static RemoteDataSource newInstance(){ return new RemoteDataSourceImp(); } @Override public void excuteRequest(String url, int requestId, String tag, StringBodyRequest.RequestResultListener resultListener) { excuteRequest(url,null,requestId,tag,resultListener); } @Override public void excuteRequest(String url, Map<String, String> headrMap, int requestId, String tag, StringBodyRequest.RequestResultListener resultListener) { excuteRequest(Request.Method.GET,url,null,headrMap,requestId,tag,resultListener); } @Override public void excuteRequest(int method, String url, JSONObject jsonObject, Map<String, String> headrMap, int requestId, String tag, StringBodyRequest.RequestResultListener resultListener) { excuteRequest(createRequest(method,url,jsonObject,headrMap,requestId,tag,resultListener)); } @Override public void excuteRequest(int method, String url, JSONObject jsonObject, int requestId, String tag, StringBodyRequest.RequestResultListener resultListener) { excuteRequest(Request.Method.GET,url,jsonObject,null,requestId,tag,resultListener); } /** * 创建不同body,Header的请求 * @param method * @param url * @param jsonObject * @param headrMap * @param requestId * @param tag * @param resultListener * @return */ private StringBodyRequest createRequest(int method, String url,JSONObject jsonObject, Map<String,String> headrMap, int requestId,String tag,StringBodyRequest.RequestResultListener resultListener){ StringBodyRequest stringBodyRequest=new StringBodyRequest(method,url,jsonObject,requestId,resultListener); stringBodyRequest.setTag(tag); if(headrMap!=null){ Set<Map.Entry<String,String>> headerSet = headrMap.entrySet(); for (Map.Entry<String,String> entry:headerSet){ stringBodyRequest.setHeader(entry.getKey(),entry.getValue()); } } return stringBodyRequest; } /** * 执行 Request * @param stringBodyRequest */ private void excuteRequest(StringBodyRequest stringBodyRequest){ VolleySingle.getInstance().addRequest(stringBodyRequest); }}
远程数据源的包结构图如下:
3. 实际业务模块:
这里,列举:电影列表界面的模块
View告诉Presenter要加载数据,Presenter要获取远程数据源,然后回调的响应数据更新到UI上.
View告诉Presenter要收藏的电影,Presenter将收藏数据传递给本地数据源,进行存储,最后Presenter将存储结果更新到UI上。
根据上面的View,Presenter间的交互关系,抽出其行为:
public interface MovieListConstract { interface Presenter extends BasePresenter{ /** * 收藏的数据 */ void collectionMovie(List<Movie> list); } interface View extends BaseView<MovieListConstract.Presenter>{ /** * 加载从数据源中获取的数据 */ void loadMovieList(List<Movie> list); /** * 显示最新信息 */ void showToast(String s); }}
接着编写View接口的具体实现类Fragment:
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); initView(); //开始加载远程任务 this.presenter.start(); } /** * 初始化控件 */ private void initView() { this.recyclerView = (RecyclerView) this.rootView.findViewById(R.id.movielist_recyclerView); this.adapter = new MovieListAdapter(); this.recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); this.recyclerView.setAdapter(this.adapter); this.recyclerView.addItemDecoration(new BaseItemDecoration(getActivity())); this.rootView.findViewById(R.id.movielist_collection_btn).setOnClickListener(this); swipeRefreshLayout = (ScrollChildSwipeRefreshLayout) rootView.findViewById(R.id.movielist_refreshLayout); swipeRefreshLayout.setColorSchemeColors(Color.parseColor("#263238"), Color.parseColor("#ffffff"), Color.parseColor("#455A64")); swipeRefreshLayout.setScrollUpChild(recyclerView); swipeRefreshLayout.setOnRefreshListener(this); //自动加载下拉提示框 setLoadingIndicator(true); //以上代码不响应onRefresh(),需要手动响应onReFresh()。 this.onRefresh(); } /** * 控制SwipeRefreshLayout的显示与隐藏 * * @param active */ public void setLoadingIndicator(final boolean active) { if (swipeRefreshLayout == null) { return; } /** * 通过swipeRefreshLayout.post来调用swipeRefreshLayout.setRefreshing()来实现,一进入页面就自动下拉提示窗。 */ swipeRefreshLayout.post(new Runnable() { @Override public void run() { //确保布局加载完成后,调用 swipeRefreshLayout.setRefreshing(active); } }); } @Override public void onDestroyView() { //解除对View的引用 this.presenter.unbindView(); super.onDestroyView(); } 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()); } } @Override public void onRefresh() { swipeRefreshLayout.postDelayed(new Runnable() { @Override public void run() { setLoadingIndicator(false); } }, 1000 * 2); }}
在接下来编写Presenter的实现类:
public class MovieListPresenter implements MovieListConstract.Presenter, StringBodyRequest.RequestResultListener { private MovieListConstract.View view; private LocalDataSource<MovieData> localDataSource; private RemoteDataSource remoteDataSource; public MovieListPresenter(RemoteDataSource remoteDataSource, LocalDataSource<MovieData> localDataSource, MovieListConstract.View view) { this.remoteDataSource = remoteDataSource; this.localDataSource = localDataSource; this.view = view; this.view.setPresenter(this); } @Override public void start() { loadRemoteTask(); } /** * 豆瓣中电影的Api: */ private final String URL = "https://api.douban.com/v2/movie/search?q=张艺谋"; private final int REQUEST_MOVIELIST = 1; private final String TAG = MovieListPresenter.class.getSimpleName(); /** * 开始加载远程的数据 */ private void loadRemoteTask() { remoteDataSource.excuteRequest(URL, REQUEST_MOVIELIST, TAG, this); } /** *Presenter将收藏的数据传递给本地数据源,进行存储。 */ @Override public void collectionMovie(List<Movie> list) { List<MovieData> movieDataList = new ArrayList<>(); for (Movie movie : list) { movieDataList.add(TransformUtils.transformMovies(movie)); } //本地数据源将收藏的电影存储,将结果反馈给Presenter。 int size= this.localDataSource.bulkInsert(movieDataList); if(size>0){//批量插入成功 if(isViewBind()){//Presenter传递数据到View上,进行UI更新 this.view.showToast("收藏成功,可在收藏页面查看"); } } } @Override public void unbindView() { this.view = null; } @Override public void success(int requestId, String response) { Log.i(TAG," 响应的数据 "+response); switch (requestId) { case REQUEST_MOVIELIST://响应成功,解析数据 List<Movie> list = GsonUtils.paserJson(response, MovieList.class).getSubjects(); // Presenter将数据传递到View上, 进行UI更新 this.view.loadMovieList(list); this.view.showToast("获取列表成功"); break; default: break; } } @Override public void failure(int requestId, VolleyError error) { switch (requestId) { case REQUEST_MOVIELIST: if (isViewBind()) { this.view.showToast("加载失败"); } break; default: break; } } /** * 检查View是否被绑定 * * @return */ private boolean isViewBind() { return this.view == null ? false : true; }}
最后,在Activity中创建View 和 Presenter对象:
public class MovieListActivity extends AppCompatActivity implements NavigationView.OnNavigationItemSelectedListener , View.OnClickListener{ private MovieListConstract.Presenter presenter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_movielist); initView(); 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(RemoteDataSourceImp.newInstance(),new MovieLocalSource(ContentResolverUtils.createResolver(BaseApplication.getAppContext())),fragment); } /** * 初始化控件 */ private void initView() { NavigationView navigationView=(NavigationView) this.findViewById(R.id.movielist_navigationview); FloatingActionButton floationActionButton=(FloatingActionButton) this.findViewById(R.id.movielist_floationActionBtn); floationActionButton.setOnClickListener(this); navigationView.setNavigationItemSelectedListener(this); } @Override public boolean onNavigationItemSelected(@NonNull MenuItem item) { switch (item.getItemId()){ case R.id.activity_movielist_drawer_collect://转调收藏电影的界面. Intent intent=new Intent(this, CollectionMovieActivity.class); startActivity(intent); break; case R.id.activity_movielist_drawer_movielist: break; } //关闭抽屉菜单 DrawerLayout drawerLayout=(DrawerLayout) this.findViewById(R.id.movielist_drawer); drawerLayout.closeDrawer(GravityCompat.START); return true; } @Override public void onClick(View v) { Snackbar.make(v,"MVP案例",Snackbar.LENGTH_SHORT).setAction("Action",null).show(); } @Override public void onBackPressed() { DrawerLayout drawerLayout=(DrawerLayout) this.findViewById(R.id.movielist_drawer); if(drawerLayout.isDrawerOpen(GravityCompat.START)){//按Back键,关闭抽屉菜单。 drawerLayout.closeDrawer(GravityCompat.START); }else{ super.onBackPressed(); } }}
电影收藏的业务也是类似,只要要抽出View与Presenter的交互行为,剩下的便是调用数据源。
最好,可以结合Android MVP架构来加深理解。
4. 其他配置:
一个具备添加数据的Adapter抽象类:
public abstract class BaseRecyclerViewAdapter<T ,VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<VH> { public abstract void upData(T t);}
一个支持非直接子类滚动视图的SwipeRefreshLayout:
public 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(); }}
一些其他的工具类:
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包结构图如下:
5. 项目运行效果如下:
本项目代码:https://github.com/13767004362/MVPDemo
todo-mvp-contentproviders官方案例
1. 项目结构图:
2. 项目链接:https://github.com/googlesamples/android-architecture/tree/todo-mvp-contentproviders/
s/
- Android MVP架构(Volley+CursorLoader+ContentProvider)
- MVP (1)- Android mvp 架构的自述
- Android CursorLoader实例详解(附源码)
- Android Volley架构分析
- Android MVP架构浅析(续)
- Android MVP架构学习(附demo)
- Android架构(一)MVP全解析
- Android架构(一)MVP全解析
- Android架构(一)MVP全解析
- Android中的MVP架构总结(一)
- Android中MVP架构总结(二)
- Android架构(一)MVP全解析
- Android MVP架构浅析
- android MVP架构
- Android MVC、MVP架构
- android MVP架构
- Android MVP 架构示例
- android mvp架构 浅尝辄止
- PAT1059 C语言竞赛(20)
- opencv 霍夫直线变换
- ParallaxListView
- 学习MVC之租房网站(九)-房源显示和搜索
- NYoj 51管闲事的小明
- Android MVP架构(Volley+CursorLoader+ContentProvider)
- 301重定向html网页跳转代码实例
- ES6 中 Class 学习笔记
- Codeforces Round #413 C. Fountains
- Linux命令基础18-文件使用chmod命令
- C 语言字符数组的定义与初始化
- gif屏幕录制工具
- POJ1458--Common Subsequence(dp)
- jQuery源码-jQuery.extend