全文
如何用一周时间开发一款Android APP并在Google Play上线的合集 - 下
如何用一周时间开发一款Android APP并在Google Play上线 - Day 1
如何用一周时间开发一款Android APP并在Google Play上线 - Day 2
如何用一周时间开发一款Android APP并在Google Play上线 - Day 3
如何用一周时间开发一款Android APP并在Google Play上线 - Day 4
如何用一周时间开发一款Android APP并在Google Play上线 - Day 5
如何用一周时间开发一款Android APP并在Google Play上线 - Day 6
如何用一周时间开发一款Android APP并在Google Play上线 - Day 7
目标:实现纸飞机App - 采用MVP架构,集合了知乎日报、果壳精选和豆瓣一刻的综合性阅读客户端。效果图如下所示:
PaperPlane
本次教程分为7天,内容分别为:
第一天,准备
功能需求
可行性分析
其他准备
第二天,UI
选择合适的UI
第三天,整体架构
第四天,首页列表
界面编写
实体类
显示数据
缓存内容
第五天,详情页与其他
界面编写
实体类
显示数据
设置与关于
第六天,高级功能
夜间模式
版本适配
第七天,发布与开源
在Google Play上线
在GitHub开源
思考
好了,废话不多说了。现在就开始吧。
DAY 1
俗话说,万事开头难,准备工作做好了,可以起到事半功倍的作用。磨刀不误砍柴工嘛。
Day 1,功能需求
在开始正式编码之前,咱们还是得先把要实现的功能一一列出来,后面实现起来才有方向嘛。我认为咱们需要实现的功能有:
正确获取消息列表并展示
能够获取历史消息
展示内容详情
后台自动缓存内容详情,方便用户在无网络连接时查看
收藏特定消息
夜间模式
一共6个大的需求,不多,但是我们仔细的研究一下,实际上这6个需求涉及到了网络,UI,数据存储,后台服务等内容。相信对于聪明的你不算困难,现在我们来研究一下可行性。
Day 1,可行性分析
我们首先需要考虑的问题就是:数据从哪里来?感谢开源,GitHub上izzyleung大神分析了知乎日报的API并开源了,项目地址请戳这里:知乎日报 API 分析,分析的非常详细,纸飞机项目在初期,也就是版本3.0之前也只使用了这一个API,在3.0之后还使用果壳精选和豆瓣一刻的API。如果你还想要展示更多的内容,可以戳这里:Awsome_API,收集了一些国内外常用的API。
我们来粗略的看一下数据的内容。获取知乎日报2017年1月22日的消息列表:
http://news-at.zhihu.com/api/4/news/before/20170122
服务器向我们返回JSON格式的内容:
{"date":"20170121","stories": [ {"images": ["http://pic1.zhimg.com/ffcca2b2853f2af791310e6a6d694e80.jpg"],"type":0,"id":9165434,"ga_prefix":"012121","title":"谁说普通人的生活就不能精彩有趣呢?"}, ... ]}
OK,获取到了列表之后,我们就可以获取详细的内容了,例如,我们获取id为9165434的内容,只需要将id拼接到http://news-at.zhihu.com/api/4/news/之后:
http://news-at.zhihu.com/api/4/news/9165434
获取到的内容为:
{
"body": "html格式的内容",
"image_source": "《帕特森》",
"title": "谁说普通人的生活就不能精彩有趣呢?",
"image": "http://pic4.zhimg.com/e39083107b7324c6dbb725da83b1d7fb.jpg",
"share_url": "http://daily.zhihu.com/story/9165434",
"js": [],
"ga_prefix": "012121",
"section": {
"thumbnail": "http://pic1.zhimg.com/ffcca2b2853f2af791310e6a6d694e80.jpg",
"id": 28,
"name": "放映机"
},
"images": [
"http://pic1.zhimg.com/ffcca2b2853f2af791310e6a6d694e80.jpg"
],
"type": 0,
"id": 9165434,
"css": [
"http://news-at.zhihu.com/css/news_qa.auto.css?v=4b3e3"
]
}
body字段中就是html格式的内容详情,我们就可以使用WebView来展示了。当然,知乎日报的API接口不止上面的两个,你可以点击上面的链接查看。获取果壳精选和豆瓣一刻的内容,你可以在我的项目中直接查看文件Api。
Day 1,其他准备
工欲善其事,必先利其器。工具准备好总是没错的。
一台电脑 这个怎么说呢,没有这个的话,要进行开发工作还是很难的,咱们总不能用石器写代码吧。
软件:
Android Studio标配
Chrome程序员用360浏览器,百度浏览器什么的总觉得有点不够GEEK。
Postman一款功能强大的网页调试与发送网页HTTP请求的Chrome插件,我们做网络请求分析时需要用到。
Genymotion如果你嫌AS自带的模拟器慢的话,可以试试这个。
Git版本控制,命令行敲起来炒鸡带感哦。
最好是能有一台Android手机。
科学上网,确保能够正常访问Google和StackOverFlow。让百度去死吧。
好了,第一天的工作差不多就是这么多,熟悉一下API,把工具备好,收拾一下心情,准备明天的工作。
DAY 2
今天主要完成的是UI设计。你可能会问了,这不是设计师的工作么。然而,我在开发纸飞机的过程中,并没有射鸡湿这种生物,UI就我自己完成了。相信大多数的程序员,美术方面应该不是那么地擅长。
当然,有美术和相关基础的同学可以试试用Sketch或者PS把原型图画出来,对于没有美术基础的童鞋,最简单的方法当然就是模仿现成的APP了。当然,你也可以在下列网站寻找合适的设计图:
Dribbble
UpLabs
UI中国
站酷ZCOOL
另外,还有一些小的注意事项:
遵守Material Design设计规范- 这不是强制性的要求,但是,既然我们是开发一款Android App,如果我们自己都不遵守规范,还怎么指望Android环境变好呢。
正确使用BottomNavigation- BottomNavigation作为Google的打脸之作,诞生之初就倍受争议。我个人的建议是使用TabLayout代替底部导航,这是涉及到信仰的大事情。如果一定要用,请不要把iOS上的标准直接放在Android上使用,请参考这一篇文章:Material Design 中的 Bottom Navigation 并不是无脑移植 iOS 导航模式的许可证,并且,我向你投来一个鄙视的眼神。
使用正确的图标 - 尽量使用https://material.io/icons/网站上的图标,如果你使用iOS版本的图标,我再次向你投来一个鄙视的眼神。
纸飞机的最终设计效果如下:
PaperPlane
首页使用Drawer作为顶级导航,Tab为二级导航,列表项使用卡牌布局,使用FloatingActionButton作为日期选择按钮;详情页面使用可收缩的Toolbar,图片搭配文字的形式。其他高深的我也不懂了。(到后面你会发现,这里我犯了一个错误,卡牌布局用在这里是不合适的。参见:https://material.io/guidelines/components/cards.html#cards-usage)
DAY 3
现在开始就要真正的写代码了。
新建Android Studio项目什么的就不说了,下面的是我的项目结构图:
项目结构
·
├── app
| ├── libs 存放相关的jar文件等
| ├── src
| | ├── androidTest 测试相关目录
| | ├── main
| | | ├── assets 存放资源原文件
| | | ├── java
| | | | ├── com.marktony.zhihudaily java包
| | | | | ├── about 关于页面
| | | | | ├── adapter RecyclerView与ViewPager等控件的Adapter
| | | | | ├── app Application
| | | | | ├── bean 存放实体类
| | | | | ├── bookmarks 收藏页面
| | | | | ├── customtabs Chrome Custom Tabs相关
| | | | | ├── db 数据库相关
| | | | | ├── detail 详细内容页面
| | | | | ├── homepage 首页页面
| | | | | ├── innerbrowser 内置浏览器页面
| | | | | ├── interfaze 接口集合
| | | | | ├── license 开源许可证页面
| | | | | ├── search 搜索页面
| | | | | ├── service Service集合
| | | | | ├── settings 设置页面
| | | | | ├── util 工具类集合
| | | | | ├── BasePresenter.java Presenter基类
| | | | | ├── BaseView.java View基类
| | | ├── res
| | | ├── AndroidManifest.xml 清单文件
(不难看出,我是按照页面和功能进行分包的。)
包建立完成后,我们开始导入第三方的开源库,便于简化代码的编写和实现特定的效果。找到工程目录下app文件夹,打开build.gradle文件,添加如下内容。
dependencies {
compile fileTree(include: ['*.jar'], dir: 'libs')
// 使用volley简化网络请求
compile files('libs/library-1.0.19.jar')
// appcompat兼容包
compile 'com.android.support:appcompat-v7:25.1.0'
// material design 设计包
compile 'com.android.support:design:25.1.0'
// recycler view控件
compile 'com.android.support:recyclerview-v7:25.1.0'
// preference screen 设置和关于页面的配置
compile 'com.android.support:preference-v14:25.1.0'
// 支持Chrome Custom Tabs
compile 'com.android.support:customtabs:25.1.0'
// card view 控件
compile 'com.android.support:cardview-v7:25.1.0'
// 解析JSON数据
compile 'com.google.code.gson:gson:2.7'
// 图片加载
compile 'com.github.bumptech.glide:glide:3.7.0'
// 为了保持在低版本SDK中的UI一致性,引入material data time picker库
compile 'com.wdullaer:materialdatetimepicker:2.5.0'
testCompile 'junit:junit:4.12'
由于一些历史遗留问题,我并没有使用OkHttp作为网络请求包,而是选择了volley。如果你有一定的基础,可以选择使用OkHttp。
导入volley有两种方式:
在app目录下的lib目录下粘贴volley的jar包,你可以在这里下载到:Volley。
当然也可以通过gradle引入。
compile 'com.android.volley:volley:1.0.0'
然后点击Sync Project with Gradle files。
首先是整体的架构:MVP。关于整体架构的选择以及更加详细的介绍部分,可以戳这篇文章:重构!将Google-MVP应用于已有项目。这里我们仿照Google的Android Architecture Blueprints [beta]中的todo-mvp。
首先创建最基本的BaseView和BasePresenter,他们分别是所有View和Presenter的基类。
Baseview.java
publicinterfaceBaseView{// 为View设置PresentervoidsetPresenter(T presenter);// 初始化界面控件voidinitViews(View view); }
BasePresenter.java
publicinterfaceBasePresenter{// 获取数据并改变界面显示,在todo-mvp的项目中的调用时机为Fragment的OnResume()方法中voidstart(); }
然后创建一个契约类,用于同一管理View和Presenter。这里以知乎日报的部分为例(如果没有特别说明,后面的代码均以知乎日报的部分为例,果壳精选与豆瓣一刻的代码类似,详细代码可以在GitHub的repo中找到)。
ZhihuDailyContract.java
publicinterfaceZhihuDailyContract{interfaceViewextendsBaseView{// 显示加载或其他类型的错误voidshowError();// 显示正在加载voidshowLoading();// 停止显示正在加载voidstopLoading();// 成功获取到数据后,在界面中显示voidshowResults(ArrayList list);// 显示用于加载指定日期的date picker dialogvoidshowPickDialog(); }interfacePresenterextendsBasePresenter{// 请求数据voidloadPosts(longdate,booleanclearing);// 刷新数据voidrefresh();// 加载更多文章voidloadMore(longdate);// 显示详情voidstartReading(intposition);// 随便看看voidfeelLucky(); } }
在上面已经分好的子包中,创建相应的子类View和Presenter。
ZhihuDailyFragment.java
```java
public class ZhihuDailyFragment extends Fragment
implements ZhihuDailyContract.View {
public ZhihuDailyFragment() {}
public static ZhihuDailyFragment newInstance() {
return new ZhihuDailyFragment();
}
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return null;
}
@Override
public void setPresenter(ZhihuDailyContract.Presenter presenter) {
}
@Override
public void initViews(View view) {
}
@Override
public void showError() {
}
@Override
public void showLoading() {
}
@Override
public void stopLoading() {
}
@Override
public void showResults(ArrayList list) {
}
@Override
public void showPickDialog() {
}
}
```
[ZhihuDailyPresenter.java](https://github.com/TonnyL/PaperPlane/blob/master/app/src/main/java/com/marktony/zhihudaily/homepage/ZhihuDailyPresenter.java)
```java
public class ZhihuDailyPresenter implements ZhihuDailyContract.Presenter {
public ZhihuDailyPresenter(Context context, ZhihuDailyContract.View view) {
}
@Override
public void loadPosts(long date, final boolean clearing) {
}
@Override
public void refresh() {
}
@Override
public void loadMore(long date) {
}
@Override
public void startReading(int position) {
}
@Override
public void feelLucky() {
}
@Override
public void start() {
}
}
```
然后完成果壳精选页面,豆瓣一刻的内容,就可以进行下面的工作了。
创建VolleySingleton,即Volley的单例。这样,整个应用就可以只维护一个请求队列,加入新的网络请求也会更加的方便。
VolleySingleton.java
publicclassVolleySingleton{privatestaticVolleySingleton volleySingleton;privateRequestQueue requestQueue;privateVolleySingleton(Context context){ requestQueue = Volley.newRequestQueue(context.getApplicationContext()); }publicstaticsynchronizedVolleySingletongetVolleySingleton(Context context){if(volleySingleton ==null){ volleySingleton =newVolleySingleton(context); }returnvolleySingleton; }publicRequestQueuegetRequestQueue(){returnthis.requestQueue; }publicvoidaddToRequestQueue(Request req){ getRequestQueue().add(req); } }
然后是Model层的实现。使用了Gson之后,对JSON的转换更加方便了,所以,我们只需要返回类型为String即可。
OnStringListener.java
publicinterfaceOnStringListener{/** * 请求成功时回调 *@paramresult */voidonSuccess(String result);/** * 请求失败时回调 *@paramerror */voidonError(VolleyError error); }
定义了两个方法,分别为请求成功时和请求失败时的回调。
然后定义一个StringModel的实现类–StringModelImpl。
StringModelImpl.java
publicclassStringModelImpl{privateContext context;publicStringModelImpl(Context context){this.context = context; }publicvoidload(String url,finalOnStringListener listener){ StringRequest request =newStringRequest(url,newResponse.Listener() {@OverridepublicvoidonResponse(String s){ listener.onSuccess(s); } },newResponse.ErrorListener() {@OverridepublicvoidonErrorResponse(VolleyError volleyError){ listener.onError(volleyError); } }); VolleySingleton.getVolleySingleton(context).addToRequestQueue(request); } }
到这里,基本的架构就搭建完成了。现在可以喝杯咖啡,然后完成今天的最后一点工作,为后面的工作做准备。
创建Api.java文件,用于存储app所用到的所有API。
Api.java
publicclassApi{// 消息内容获取与离线下载// 在最新消息中获取到的id,拼接到这个NEWS之后,可以获得对应的JSON格式的内容publicstaticfinalString ZHIHU_NEWS ="http://news-at.zhihu.com/api/4/news/";// 过往消息// 若要查询的11月18日的消息,before后面的数字应该为20161118// 知乎日报的生日为2013 年 5 月 19 日,如果before后面的数字小于20130520,那么只能获取到空消息publicstaticfinalString ZHIHU_HISTORY ="http://news.at.zhihu.com/api/4/news/before/";// 获取果壳精选的文章列表,通过组合相应的参数成为完整的urlpublicstaticfinalString GUOKR_ARTICLES ="http://apis.guokr.com/handpick/article.json?retrieve_type=by_since&category=all&limit=25&ad=1";// 获取果壳文章的具体信息 V1publicstaticfinalString GUOKR_ARTICLE_LINK_V1 ="http://jingxuan.guokr.com/pick/";// 豆瓣一刻// 根据日期查询消息列表publicstaticfinalString DOUBAN_MOMENT ="https://moment.douban.com/api/stream/date/";// 获取文章具体内容publicstaticfinalString DOUBAN_ARTICLE_DETAIL ="https://moment.douban.com/api/post/";}
创建NetworkState.java文件,判断当前的网络状态,是否有网络连接,WiFi或者是移动数据。
NetworkState.java
publicclassNetworkState{// 检查是否连接到网络publicstaticbooleannetworkConnected(Context context){if(context !=null){ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = manager.getActiveNetworkInfo();if(info !=null)returninfo.isAvailable(); }returnfalse; }// 检查WiFi是否连接publicstaticbooleanwifiConnected(Context context){if(context !=null){ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = manager.getActiveNetworkInfo();if(info !=null){if(info.getType() == ConnectivityManager.TYPE_WIFI)returninfo.isAvailable(); } }returnfalse; }// 检查移动网络是否连接publicstaticbooleanmobileDataConnected(Context context){if(context !=null){ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = manager.getActiveNetworkInfo();if(info !=null){if(info.getType() == ConnectivityManager.TYPE_MOBILE)returntrue; } }returnfalse; }}
创建DateFormatter .java文件,方便将long类型的日期转换为String类型。
DateFormatter.java
publicclassDateFormatter{/** * 将long类date转换为String类型 *@paramdate date *@returnString date */publicStringZhihuDailyDateFormat(longdate){ String sDate; Date d =newDate(date +24*60*60*1000); SimpleDateFormat format =newSimpleDateFormat("yyyyMMdd"); sDate = format.format(d);returnsDate; }publicStringDoubanDateFormat(longdate){ String sDate; Date d =newDate(date); SimpleDateFormat format =newSimpleDateFormat("yyyy-MM-dd"); sDate = format.format(d);returnsDate; }}
OK,day 3工作完成。
Day 4
今天的只要任务是完成首页。
Day 4,界面编写
我们的首页,使用的是Activity + Fragment搭配的方式,即一个MainActivity + MainFragment + BookmarksFragment的方式。其中,MainActivity的布局文件中包含了DrawerLayout, Toolbar以及Fragment所在的容器。
MainActivity对应布局文件如下:
activity_main.xml
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:openDrawer="start">
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header_main"
app:menu="@menu/activity_main_drawer" />
nav_header_main.xml
android:layout_width="match_parent"
android:layout_height="@dimen/nav_header_height"
android:background="@drawable/nav_header"
android:gravity="bottom"
android:orientation="vertical"
android:theme="@style/ThemeOverlay.AppCompat.Dark">
nav_header实际上就只是一个简单的ImageView。
app_bar_main.xml
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".homepage.MainActivity">
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:elevation="0dp"
android:theme="@style/AppTheme.AppBarOverlay">
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorPrimary"
app:popupTheme="@style/AppTheme.PopupOverlay" />
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/layout_fragment"
android:layout_marginTop="?actionBarSize"/>
OK,Activity的布局文件完成。然后就可以写java代码了。
MainActivity.java
publicclassMainActivityextendsAppCompatActivityimplementsNavigationView.OnNavigationItemSelectedListener{privateMainFragment mainFragment;privateBookmarksFragment bookmarksFragment;privateNavigationView navigationView;privateDrawerLayout drawer;privateToolbar toolbar;@OverrideprotectedvoidonCreate(Bundle savedInstanceState){super.onCreate(savedInstanceState); setContentView(R.layout.activity_main);// 初始化控件initViews();// 恢复fragment的状态if(savedInstanceState !=null) { mainFragment = (MainFragment) getSupportFragmentManager().getFragment(savedInstanceState,"MainFragment"); bookmarksFragment = (BookmarksFragment) getSupportFragmentManager().getFragment(savedInstanceState,"BookmarksFragment"); }else{ mainFragment = MainFragment.newInstance(); bookmarksFragment = BookmarksFragment.newInstance(); }if(!mainFragment.isAdded()) { getSupportFragmentManager().beginTransaction() .add(R.id.layout_fragment, mainFragment,"MainFragment") .commit(); }if(!bookmarksFragment.isAdded()) { getSupportFragmentManager().beginTransaction() .add(R.id.layout_fragment, bookmarksFragment,"BookmarksFragment") .commit(); }// 实例化BookmarksPresenternewBookmarksPresenter(MainActivity.this, bookmarksFragment);// 默认显示首页内容showMainFragment(); }// 初始化控件privatevoidinitViews(){ toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); drawer = (DrawerLayout) findViewById(R.id.drawer_layout); ActionBarDrawerToggle toggle =newActionBarDrawerToggle(this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close); drawer.setDrawerListener(toggle); toggle.syncState(); navigationView = (NavigationView) findViewById(R.id.nav_view); navigationView.setNavigationItemSelectedListener(this); }// 显示MainFragment并设置TitleprivatevoidshowMainFragment(){ FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); fragmentTransaction.show(mainFragment); fragmentTransaction.hide(bookmarksFragment); fragmentTransaction.commit(); toolbar.setTitle(getResources().getString(R.string.app_name)); }// 显示BookmarksFragment并设置TitleprivatevoidshowBookmarksFragment(){ FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); fragmentTransaction.show(bookmarksFragment); fragmentTransaction.hide(mainFragment); fragmentTransaction.commit(); toolbar.setTitle(getResources().getString(R.string.nav_bookmarks)); }@OverridepublicbooleanonNavigationItemSelected(@NonNull MenuItem item){ drawer.closeDrawer(GravityCompat.START);intid = item.getItemId();if(id == R.id.nav_home) { showMainFragment(); }elseif(id == R.id.nav_bookmarks) { showBookmarksFragment(); }elseif(id == R.id.nav_change_theme) { }elseif(id == R.id.nav_settings) { }elseif(id == R.id.nav_about) { }returntrue; }// 存储Fragment的状态@OverrideprotectedvoidonSaveInstanceState(Bundle outState){super.onSaveInstanceState(outState);if(mainFragment.isAdded()) { getSupportFragmentManager().putFragment(outState,"MainFragment", mainFragment); }if(bookmarksFragment.isAdded()) { getSupportFragmentManager().putFragment(outState,"BookmarksFragment", bookmarksFragment); } }}
从代码中可以看出,MainActivity负责处理DrawerLayout的点击事件,即控制显示或者隐藏特定的Fragment。而Fragment的状态的保存与恢复也是在这里进行的。
MainFragment.java
publicclassMainFragmentextendsFragment{privateContext context;privateMainPagerAdapter adapter;privateTabLayout tabLayout;privateZhihuDailyFragment zhihuDailyFragment;privateGuokrFragment guokrFragment;privateDoubanMomentFragment doubanMomentFragment;privateZhihuDailyPresenter zhihuDailyPresenter;privateGuokrPresenter guokrPresenter;privateDoubanMomentPresenter doubanMomentPresenter;publicMainFragment(){}publicstaticMainFragmentnewInstance(){returnnewMainFragment(); }@OverridepublicvoidonAttach(Context context){super.onAttach(context); }@OverridepublicvoidonCreate(@Nullable Bundle savedInstanceState){super.onCreate(savedInstanceState);this.context = getActivity();// Fragment状态恢复if(savedInstanceState !=null) { FragmentManager manager = getChildFragmentManager(); zhihuDailyFragment = (ZhihuDailyFragment) manager.getFragment(savedInstanceState,"zhihu"); guokrFragment = (GuokrFragment) manager.getFragment(savedInstanceState,"guokr"); doubanMomentFragment = (DoubanMomentFragment) manager.getFragment(savedInstanceState,"douban"); }else{// 创建View实例zhihuDailyFragment = ZhihuDailyFragment.newInstance(); guokrFragment = GuokrFragment.newInstance(); doubanMomentFragment = DoubanMomentFragment.newInstance(); }// 创建Presenter实例zhihuDailyPresenter =newZhihuDailyPresenter(context, zhihuDailyFragment); guokrPresenter =newGuokrPresenter(context, guokrFragment); doubanMomentPresenter =newDoubanMomentPresenter(context, doubanMomentFragment); }@Nullable@OverridepublicViewonCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState){ View view = inflater.inflate(R.layout.fragment_main, container,false);// 初始化控件initViews(view);// 显示菜单setHasOptionsMenu(true);// 当tab layout位置为果壳精选时,隐藏fabtabLayout.addOnTabSelectedListener(newTabLayout.OnTabSelectedListener() {@OverridepublicvoidonTabSelected(TabLayout.Tab tab){ FloatingActionButton fab = (FloatingActionButton) getActivity().findViewById(R.id.fab);if(tab.getPosition() ==1) { fab.hide(); }else{ fab.show(); } }@OverridepublicvoidonTabUnselected(TabLayout.Tab tab){ }@OverridepublicvoidonTabReselected(TabLayout.Tab tab){ } });returnview; }// 初始化控件privatevoidinitViews(View view){ tabLayout = (TabLayout) view.findViewById(R.id.tab_layout); ViewPager viewPager = (ViewPager) view.findViewById(R.id.view_pager);// 设置离线数为3viewPager.setOffscreenPageLimit(3); adapter =newMainPagerAdapter( getChildFragmentManager(), context, zhihuDailyFragment, guokrFragment, doubanMomentFragment); viewPager.setAdapter(adapter); tabLayout.setupWithViewPager(viewPager); }@OverridepublicvoidonCreateOptionsMenu(Menu menu, MenuInflater inflater){super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.main, menu); }@OverridepublicbooleanonOptionsItemSelected(MenuItem item){intid = item.getItemId();if(id == R.id.action_feel_lucky) { feelLucky(); }returntrue; }// 保存状态@OverridepublicvoidonSaveInstanceState(Bundle outState){super.onSaveInstanceState(outState); FragmentManager manager = getChildFragmentManager(); manager.putFragment(outState,"zhihu", zhihuDailyFragment); manager.putFragment(outState,"guokr", guokrFragment); manager.putFragment(outState,"douban", doubanMomentFragment); }// 随便看看publicvoidfeelLucky(){ Random random =newRandom();inttype = random.nextInt(3);switch(type) {case0: zhihuDailyPresenter.feelLucky();break;case1: guokrPresenter.feelLucky();break;default: doubanMomentPresenter.feelLucky();break; } }publicMainPagerAdaptergetAdapter(){returnadapter; }}
首页的MainFragment主要负责显示与TabLayout + ViewPager相关的内容。
OK,终于把首页的UI框架搭建好了,喝杯咖啡,休息一下,冷静冷静。
现在开始实现具体的ZhihuDailyFragment的布局。仔细观察,实际上,ZhihuDailyFragment所包含的控件就只有一个RecyclerView,将获取到的内容以列表的形式显示出来。并且,不难发现,果壳精选与豆瓣一刻的布局与知乎日报的列表布局相同,可以复用。
fragment_list.xml
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/refreshLayout">
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:clickable="true">
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/recyclerView"
android:scrollbars="vertical"
android:scrollbarFadeDuration="1"
android:fadeScrollbars="true"/>
布局实际上还包含了SwipeRefreshLayout,用于显示正在加载和手动刷新。
列表子项的布局有很多种,分别是:
普通仅文字
普通文字 + 图片
头部项,用于显示子项类型(如知乎日报,在收藏页面会用到)
底部项,加载更多等
home_list_item_without_image.xml- 普通仅文字
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="96dp"
android:layout_width="match_parent"
android:focusable="true"
android:clickable="true"
android:foreground="?android:attr/selectableItemBackground"
app:cardCornerRadius="4dp"
app:cardElevation="1dp"
app:cardPreventCornerOverlap="true"
android:layout_marginTop="8dp"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp">
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/textViewTitle"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:gravity="center_vertical"
android:maxLines="3"
android:ellipsize="end"
android:textSize="18sp" />
home_list_item_layout.xml- 普通文字 + 图片
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="96dp"
android:layout_width="match_parent"
android:focusable="true"
android:clickable="true"
android:foreground="?android:attr/selectableItemBackground"
app:cardCornerRadius="4dp"
app:cardElevation="1dp"
app:cardPreventCornerOverlap="true"
android:layout_marginTop="8dp"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp">
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingLeft="8dp"
android:paddingRight="8dp" >
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:id="@+id/textViewTitle"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:gravity="center_vertical"
android:maxLines="3"
android:ellipsize="end"
android:textSize="18sp" />
android:layout_width="80dp"
android:layout_height="80dp"
android:id="@+id/imageViewCover"
android:layout_gravity="center_vertical" />
bookmark_header.xml- 头部项
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/textViewType"
android:paddingLeft="8dp"
android:paddingStart="8dp"
android:paddingRight="8dp"
android:paddingEnd="8dp"
android:paddingTop="8dp"
android:gravity="center_vertical"
android:textColor="@color/colorPrimary"
android:textAllCaps="true"/>
list_footer.xml- 底部项,加载更多
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:gravity="center_horizontal"
android:background="@color/viewBackground">
android:id="@+id/address_looking_up"
style="?android:attr/progressBarStyleInverse"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:visibility="visible" />
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:text="@string/loading_more"
android:layout_marginLeft="16dp"
android:layout_marginStart="8dp"
android:gravity="center_vertical"/>
布局文件到这里基本就完成了。
Day 4,实体类
我们可以直接通过JSON格式的返回数据设计实体类。可以手动编写代码,也可以利用Android Studio插件GsonFormat实现。
Json格式数据:
{"date":"20170121","stories": [ {"images": ["http://pic1.zhimg.com/ffcca2b2853f2af791310e6a6d694e80.jpg"],"type":0,"id":9165434,"ga_prefix":"012121","title":"谁说普通人的生活就不能精彩有趣呢?"}, ... ]}
对应的bean:ZhihuDailyNews.java
publicclassZhihuDailyNews{privateString date;privateArrayList stories;publicStringgetDate(){returndate; }publicvoidsetDate(String date){this.date = date; }publicArrayListgetStories(){returnstories; }publicvoidsetStories(ArrayList stories){this.stories = stories; }publicclassQuestion{privateArrayList images;privateinttype;privateintid;privateString ga_prefix;privateString title;publicArrayListgetImages(){returnimages; }publicvoidsetImages(ArrayList images){this.images = images; }publicintgetType(){returntype; }publicvoidsetType(inttype){this.type = type; }publicintgetId(){returnid; }publicvoidsetId(intid){this.id = id; }publicStringgetGa_prefix(){returnga_prefix; }publicvoidsetGa_prefix(String ga_prefix){this.ga_prefix = ga_prefix; }publicStringgetTitle(){returntitle; }publicvoidsetTitle(String title){this.title = title; } }}
Day 4,显示数据
首先,我们得有一个adapter。
ZhihuDailyNewsAdapter.java
publicclassZhihuDailyNewsAdapterextendsRecyclerView.Adapter{privatefinalContext context;privatefinalLayoutInflater inflater;privateList list =newArrayList();privateOnRecyclerViewOnClickListener mListener;// 文字 + 图片privatestaticfinalintTYPE_NORMAL =0;// footer,加载更多privatestaticfinalintTYPE_FOOTER =1;publicZhihuDailyNewsAdapter(Context context, List list){this.context = context;this.list = list;this.inflater = LayoutInflater.from(context); }@OverridepublicRecyclerView.ViewHolderonCreateViewHolder(ViewGroup parent,intviewType){// 根据ViewType加载不同布局switch(viewType) {caseTYPE_NORMAL:returnnewNormalViewHolder(inflater.inflate(R.layout.home_list_item_layout, parent,false), mListener);caseTYPE_FOOTER:returnnewFooterViewHolder(inflater.inflate(R.layout.list_footer, parent,false)); }returnnull; }@OverridepublicvoidonBindViewHolder(RecyclerView.ViewHolder holder,intposition){// 对不同的ViewHolder做不同的处理if(holderinstanceofNormalViewHolder) { ZhihuDailyNews.Question item = list.get(position);if(item.getImages().get(0) ==null){ ((NormalViewHolder)holder).itemImg.setImageResource(R.drawable.placeholder); }else{ Glide.with(context) .load(item.getImages().get(0)) .asBitmap() .placeholder(R.drawable.placeholder) .diskCacheStrategy(DiskCacheStrategy.SOURCE) .error(R.drawable.placeholder) .centerCrop() .into(((NormalViewHolder)holder).itemImg); } ((NormalViewHolder)holder).tvLatestNewsTitle.setText(item.getTitle()); } }// 因为含有footer,返回值需要 + 1@OverridepublicintgetItemCount(){returnlist.size() +1; }@OverridepublicintgetItemViewType(intposition){if(position == list.size()) {returnZhihuDailyNewsAdapter.TYPE_FOOTER; }returnZhihuDailyNewsAdapter.TYPE_NORMAL; }publicvoidsetItemClickListener(OnRecyclerViewOnClickListener listener){this.mListener = listener; }publicclassNormalViewHolderextendsRecyclerView.ViewHolderimplementsView.OnClickListener{privateImageView itemImg;privateTextView tvLatestNewsTitle;privateOnRecyclerViewOnClickListener listener;publicNormalViewHolder(View itemView, OnRecyclerViewOnClickListener listener){super(itemView); itemImg = (ImageView) itemView.findViewById(R.id.imageViewCover); tvLatestNewsTitle = (TextView) itemView.findViewById(R.id.textViewTitle);this.listener = listener; itemView.setOnClickListener(this); }@OverridepublicvoidonClick(View v){if(listener !=null){ listener.OnItemClick(v,getLayoutPosition()); } } }publicclassFooterViewHolderextendsRecyclerView.ViewHolder{publicFooterViewHolder(View itemView){super(itemView); } }}
adapter中含有两个常量,TYPE_NORMAL,TYPE_FOOTER,用于区别item的类型,从而加载不同的布局。众所周知,RecyclerView原生并没有设置item点击事件的方法,所有我们需要自己定义一个接口--OnRecyclerViewOnClickListener。
OnRecyclerViewOnClickListener.java
packagecom.marktony.zhihudaily.interfaze;importandroid.view.View;publicinterfaceOnRecyclerViewOnClickListener{voidOnItemClick(View v,intposition);}
ZhihuDailyPresenter.java
实现ZhihuDailyPresenter中的loadPosts方法,记得要在manifest清单文件中添加网络访问权限:
model.load(Api.ZHIHU_HISTORY + formatter.ZhihuDailyDateFormat(date),newOnStringListener() {@OverridepublicvoidonSuccess(String result){try{ ZhihuDailyNews post = gson.fromJson(result, ZhihuDailyNews.class);if(clearing) { list.clear(); }for(ZhihuDailyNews.Question item : post.getStories()) { list.add(item); } view.showResults(list); }catch(JsonSyntaxException e) { view.showError(); } view.stopLoading(); }@OverridepublicvoidonError(VolleyError error){ view.stopLoading(); view.showError(); } });
我们通过Gson,可以很简单将JSON格式数据转换为Java对象。
ZhihuDailyFragment
实现ZhihuDailyFragment的showResults方法。
@OverridepublicvoidshowResults(ArrayList list){if(adapter ==null) { adapter =newZhihuDailyNewsAdapter(getContext(), list); adapter.setItemClickListener(newOnRecyclerViewOnClickListener() {@OverridepublicvoidOnItemClick(View v,intposition){ presenter.startReading(position); } }); recyclerView.setAdapter(adapter); }else{ adapter.notifyDataSetChanged(); }}
Day 4,缓存内容
完成上面的代码,我们还只是实现了在有网络状态下的正常运行,如果用户并没有那么畅通无阻的网络连接呢?这个时候缓存就派上用场了,只要用户加载过一次,以后就算没有网络连接,用户也能查看之前已经离线的内容。我们选择使用Android原生SQLite数据库来存储数据(当然你也可以选择Realm)。
首先当然是要建立数据库了(由于纸飞机已经进行多个版本的迭代,所以你创建数据库的SQL语句或其他内容和我的文件应该不完全相同)。
DatabaseHelper.java
publicclassDatabaseHelperextendsSQLiteOpenHelper{publicDatabaseHelper(Context context, String name, SQLiteDatabase.CursorFactory factory,intversion){super(context, name, factory, version); }@OverridepublicvoidonCreate(SQLiteDatabase db){ db.execSQL("create table if not exists Zhihu("+"id integer primary key autoincrement,"+"zhihu_id integer not null,"+"zhihu_news text,"+"zhihu_time real,"+"zhihu_content text)"); db.execSQL("alter table Zhihu add column bookmark integer default 0"); }@OverridepublicvoidonUpgrade(SQLiteDatabase db,intoldVersion,intnewVersion){ }}
相信大牛应该看出来了,这数据库设计的真心不怎么样,因为我数据库学的确实很一般。求大牛不喷。
字段类型含义备注
idinteger主键自增长
zhihu_idinteger知乎日报消息id由知乎提供
zhihu_newstext知乎日报消息内容与Java实体类对应
zhihu_timereal知乎日报消息发布的时间由知乎提供
zhihu_contenttext知乎日报消息详细内容与Java实体类对应
bookmarkinteger是否被收藏由于SQLite并没有boolean类型,使用integer的不同值代替
OK,当我们正确请求到数据后,就可以进行存储了。
ZhihuDailyPresenter.java
if( !queryIfIDExists(item.getId())) { db.beginTransaction();try{ DateFormat format =newSimpleDateFormat("yyyyMMdd"); Date date = format.parse(post.getDate()); values.put("zhihu_id", item.getId()); values.put("zhihu_news", gson.toJson(item)); values.put("zhihu_content",""); values.put("zhihu_time", date.getTime() /1000); db.insert("Zhihu",null, values); values.clear(); db.setTransactionSuccessful(); }catch(Exception e) { e.printStackTrace(); }finally{ db.endTransaction(); }}// 查询数据库表中是否已经存在了此idprivatebooleanqueryIfIDExists(intid){ Cursor cursor = db.query("Zhihu",null,null,null,null,null,null);if(cursor.moveToFirst()){do{if(id == cursor.getInt(cursor.getColumnIndex("zhihu_id"))){returntrue; } }while(cursor.moveToNext()); } cursor.close();returnfalse;}
细心的童鞋可能发现了,诶,数据表中还有一个字段--zhihu_content,你没有存储呀。这是因为我们在请求知乎消息列表的时候,并没有返回消息的详细内容呀。不过详细内容我们还是需要缓存的,网络请求在UI线程上进行可能会引起ANR,那更好的解决办法就是在Service里面完成了。
我们先将一些必须的数据通过本地广播的形式,发送出去。
ZhihuDailyPresenter.java
Intent intent =newIntent("com.marktony.zhihudaily.LOCAL_BROADCAST");intent.putExtra("type", CacheService.TYPE_ZHIHU);intent.putExtra("id", item.getId());LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
然后在CacheService里接收广播,获取传送的数据,然后进行网络请求和数据存储。
CacheService.java
publicclassCacheServiceextendsService{privateDatabaseHelper dbHelper;privateSQLiteDatabase db;privatestaticfinalString TAG = CacheService.class.getSimpleName();publicstaticfinalintTYPE_ZHIHU =0x00;publicstaticfinalintTYPE_GUOKR =0x01;publicstaticfinalintTYPE_DOUBAN =0x02;@OverridepublicvoidonCreate(){super.onCreate(); dbHelper =newDatabaseHelper(this,"History.db",null,5); db = dbHelper.getWritableDatabase(); IntentFilter filter =newIntentFilter(); filter.addAction("com.marktony.zhihudaily.LOCAL_BROADCAST"); LocalBroadcastManager manager = LocalBroadcastManager.getInstance(this); manager.registerReceiver(newLocalReceiver(), filter); }@Nullable@OverridepublicIBinderonBind(Intent intent){returnnull; }@OverridepublicintonStartCommand(Intent intent,intflags,intstartId){returnsuper.onStartCommand(intent, flags, startId); }@OverridepublicbooleanonUnbind(Intent intent){returnsuper.onUnbind(intent); }/** * 网络请求id对应的知乎日报的内容主体 * 当type为0时,存储body中的数据 * 当type为1时,再次请求share url中的内容并储存 *@paramid 所要获取的知乎日报消息内容对应的id */privatevoidstartZhihuCache(finalintid){ Cursor cursor = db.query("Zhihu",null,null,null,null,null,null);if(cursor.moveToFirst()) {do{if((cursor.getInt(cursor.getColumnIndex("zhihu_id")) == id) && (cursor.getString(cursor.getColumnIndex("zhihu_content")).equals(""))) { StringRequest request =newStringRequest(Request.Method.GET, Api.ZHIHU_NEWS + id,newResponse.Listener() {@OverridepublicvoidonResponse(String s){ Gson gson =newGson(); ZhihuDailyStory story = gson.fromJson(s, ZhihuDailyStory.class);if(story.getType() ==1) { StringRequest request =newStringRequest(Request.Method.GET, story.getShare_url(),newResponse.Listener() {@OverridepublicvoidonResponse(String s){ ContentValues values =newContentValues(); values.put("zhihu_content", s); db.update("Zhihu", values,"zhihu_id = ?",newString[] {String.valueOf(id)}); values.clear(); } },newResponse.ErrorListener() {@OverridepublicvoidonErrorResponse(VolleyError volleyError){ } }); request.setTag(TAG); VolleySingleton.getVolleySingleton(CacheService.this).addToRequestQueue(request); }else{ ContentValues values =newContentValues(); values.put("zhihu_content", s); db.update("Zhihu", values,"zhihu_id = ?",newString[] {String.valueOf(id)}); values.clear(); } } },newResponse.ErrorListener() {@OverridepublicvoidonErrorResponse(VolleyError volleyError){ } }); request.setTag(TAG); VolleySingleton.getVolleySingleton(CacheService.this).addToRequestQueue(request); } }while(cursor.moveToNext()); } cursor.close(); }@OverridepublicvoidonDestroy(){super.onDestroy(); VolleySingleton.getVolleySingleton(this).getRequestQueue().cancelAll(TAG); }classLocalReceiverextendsBroadcastReceiver{@OverridepublicvoidonReceive(Context context, Intent intent){intid = intent.getIntExtra("id",0);switch(intent.getIntExtra("type", -1)) {caseTYPE_ZHIHU: startZhihuCache(id);break;caseTYPE_GUOKR: startGuokrCache(id);break;caseTYPE_DOUBAN: startDoubanCache(id);break;default:case-1:break; } } }}
我们先遍历一下数据库,如果数据库中指定id的消息详情内容已经不为空,那我们就直接跳过了,可以节省用户的流量以及电量。
到这里,数据的存储是完成了。可是怎么读取出来呢?哈,其实也简单,我们判断一下当前的网络状态,如果用户设备没有连接到网路,我们就直接去数据库中读取,然后解析就行了。
ZhihuDailyPresenter.java
if(NetworkState.networkConnected(context)) {// balabala}else{ Cursor cursor = db.query("Zhihu",null,null,null,null,null,null);if(cursor.moveToFirst()) {do{ ZhihuDailyNews.Question question = gson.fromJson(cursor.getString(cursor.getColumnIndex("zhihu_news")), ZhihuDailyNews.Question.class); list.add(question); }while(cursor.moveToNext()); } cursor.close(); view.stopLoading(); view.showResults(list);}
到这里,今天的工作差不多已经完成了,等等,是不是忘了什么?我们的Service并没有启动呀。
MainActivity.java
@OverrideprotectedvoidonCreate(Bundle savedInstanceState){super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initViews();// 启动服务startService(newIntent(this, CacheService.class));}@OverrideprotectedvoidonDestroy(){ ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);for(ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) {if(CacheService.class.getName().equals(service.service.getClassName())) { stopService(newIntent(this, CacheService.class)); } }super.onDestroy();}
到这里,今天的内容就算结束了,内容是一周之中最多的一天,可能比前几天的总和还要多,可能需要你加班才能完全完成,之前Activity, Presenter, Fragment中各还有一部分内容没有完成,需要你自行补充完成。不过,看到自己的App正确的跑了起来,有木有很兴奋呢?休息休息,准备明天的工作吧。
作者:TonnyL
链接:http://www.jianshu.com/p/b08e3ef22bce
來源:
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。