如何用一周时间开发一款 Android APP 并在 Google Play 上线

10,926 阅读22分钟
原文链接: www.jianshu.com

目标:实现纸飞机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了。当然,你也可以在下列网站寻找合适的设计图:

另外,还有一些小的注意事项:

  • 遵守Material Design设计规范 - 这不是强制性的要求,但是,既然我们是开发一款Android App,如果我们自己都不遵守规范,还怎么指望Android环境变好呢。
  • 正确使用BottomNavigation - BottomNavigation作为Google的打脸之作,诞生之初就倍受争议。我个人的建议是使用TabLayout代替底部导航,这是涉及到信仰的大事情。如果一定要用,请不要把iOS上的标准直接放在Android上使用,请参考这一篇文章:Material Design 中的 Bottom Navigation 并不是无脑移植 iOS 导航模式的许可证,并且,我向你投来一个鄙视的眼神。
  • 使用正确的图标 - 尽量使用 material.io/icons/ 网站上的图标,如果你使用iOS版本的图标,我再次向你投来一个鄙视的眼神。

纸飞机的最终设计效果如下:


PaperPlane

首页使用Drawer作为顶级导航,Tab为二级导航,列表项使用卡牌布局,使用FloatingActionButton作为日期选择按钮;详情页面使用可收缩的Toolbar,图片搭配文字的形式。其他高深的我也不懂了。(到后面你会发现,这里我犯了一个错误,卡牌布局用在这里是不合适的。参见:material.io/guidelines/…)

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

  1. 首先创建最基本的BaseView和BasePresenter,他们分别是所有View和Presenter的基类。

     public interface BaseView<T> {
         // 为View设置Presenter
         void setPresenter(T presenter);
        // 初始化界面控件
         void initViews(View view);
     }
     public interface BasePresenter {
         // 获取数据并改变界面显示,在todo-mvp的项目中的调用时机为Fragment的OnResume()方法中
         void start();
     }
  2. 然后创建一个契约类,用于同一管理View和Presenter。这里以知乎日报的部分为例(如果没有特别说明,后面的代码均以知乎日报的部分为例,果壳精选与豆瓣一刻的代码类似,详细代码可以在GitHub的repo中找到)。

     public interface ZhihuDailyContract {
    
         interface View extends BaseView<Presenter> {
    
             // 显示加载或其他类型的错误
             void showError();
             // 显示正在加载
             void showLoading();
             // 停止显示正在加载
             void stopLoading();
             // 成功获取到数据后,在界面中显示
             void showResults(ArrayList<ZhihuDailyNews.Question> list);
             // 显示用于加载指定日期的date picker dialog
             void showPickDialog();
    
         }
    
         interface Presenter extends BasePresenter {
             // 请求数据
             void loadPosts(long date, boolean clearing);
             // 刷新数据
             void refresh();
             // 加载更多文章
             void loadMore(long date);
             // 显示详情
             void startReading(int position);
             // 随便看看
             void feelLucky();
    
         }
    
     }
  3. 在上面已经分好的子包中,创建相应的子类View和Presenter。

    ```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<ZhihuDailyNews.Question> list) {

    }

    @Override
    public void showPickDialog() {

    }

}
```

```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() {

    }

}
```

然后完成果壳精选页面,豆瓣一刻页面,就可以进行下面的工作了。
  1. 创建VolleySingleton,即Volley的单例。这样,整个应用就可以只维护一个请求队列,加入新的网络请求也会更加的方便。

     public class VolleySingleton {
    
         private static VolleySingleton volleySingleton;
         private RequestQueue requestQueue;
    
         private VolleySingleton(Context context){
             requestQueue = Volley.newRequestQueue(context.getApplicationContext());
         }
    
         public static synchronized VolleySingleton getVolleySingleton(Context context){
             if(volleySingleton == null){
                 volleySingleton = new VolleySingleton(context);
             }
             return volleySingleton;
         }
    
         public RequestQueue getRequestQueue(){
             return this.requestQueue;
         }
    
         public <T> void addToRequestQueue(Request<T> req){
             getRequestQueue().add(req);
         }
    
     }
  2. 然后是Model层的实现。使用了Gson之后,对JSON的转换更加方便了,所以,我们只需要返回类型为String即可。

     public interface OnStringListener {
         /**
          * 请求成功时回调
          * @param result
          */
         void onSuccess(String result);
         /**
          * 请求失败时回调
          * @param error
          */
         void onError(VolleyError error);
     }

    定义了两个方法,分别为请求成功时和请求失败时的回调。

    然后定义一个StringModel的实现类–StringModelImpl。

     public class StringModelImpl {
         private Context context;
         public StringModelImpl(Context context) {
             this.context = context;
         }
         public void load(String url, final OnStringListener listener) {
             StringRequest request = new StringRequest(url, new Response.Listener<String>() {
    
     @Override
                 public void onResponse(String s) {
                     listener.onSuccess(s);
                 }
             }, new Response.ErrorListener() {
    
     @Override
                 public void onErrorResponse(VolleyError volleyError) {
                     listener.onError(volleyError);
                 }
             });
             VolleySingleton.getVolleySingleton(context).addToRequestQueue(request);
         }
     }
  3. 到这里,基本的架构就搭建完成了。现在可以喝杯咖啡,然后完成今天的最后一点工作,为后面的工作做准备。

    创建Api.java文件,用于存储app所用到的所有API。

    public class Api {
    
        // 消息内容获取与离线下载
        // 在最新消息中获取到的id,拼接到这个NEWS之后,可以获得对应的JSON格式的内容
        public static final String ZHIHU_NEWS = "http://news-at.zhihu.com/api/4/news/";
    
        // 过往消息
        // 若要查询的11月18日的消息,before后面的数字应该为20161118
        // 知乎日报的生日为2013 年 5 月 19 日,如果before后面的数字小于20130520,那么只能获取到空消息
        public static final String ZHIHU_HISTORY = "http://news.at.zhihu.com/api/4/news/before/";
    
        // 获取果壳精选的文章列表,通过组合相应的参数成为完整的url
        public static final String GUOKR_ARTICLES = "http://apis.guokr.com/handpick/article.json?retrieve_type=by_since&category=all&limit=25&ad=1";
    
        // 获取果壳文章的具体信息 V1
        public static final String GUOKR_ARTICLE_LINK_V1 = "http://jingxuan.guokr.com/pick/";
    
        // 豆瓣一刻
        // 根据日期查询消息列表
        public static final String DOUBAN_MOMENT = "https://moment.douban.com/api/stream/date/";
    
        // 获取文章具体内容
        public static final String DOUBAN_ARTICLE_DETAIL = "https://moment.douban.com/api/post/";
    
    }

    创建NetworkState.java文件,判断当前的网络状态,是否有网络连接,WiFi或者是移动数据。

    public class NetworkState {
    
        // 检查是否连接到网络
        public static boolean networkConnected(Context context){
    
            if (context != null){
                ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
                NetworkInfo info = manager.getActiveNetworkInfo();
                if (info != null)
                    return info.isAvailable();
            }
    
            return false;
        }
    
        // 检查WiFi是否连接
        public static boolean wifiConnected(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)
                        return info.isAvailable();
                }
            }
            return false;
        }
    
        // 检查移动网络是否连接
        public static boolean mobileDataConnected(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)
                        return true;
                }
            }
            return false;
        }
    
    }

    创建DateFormatter .java文件,方便将long类型的日期转换为String类型。

    public class DateFormatter {
    
        /**
         * 将long类date转换为String类型
         * @param date date
         * @return String date
         */
        public String ZhihuDailyDateFormat(long date) {
            String sDate;
            Date d = new Date(date + 24*60*60*1000);
            SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd");
            sDate = format.format(d);
    
            return sDate;
        }
    
        public String DoubanDateFormat(long date){
            String sDate;
            Date d = new Date(date);
            SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
            sDate = format.format(d);
    
            return sDate;
        }
    
    }

    OK,day 3工作完成。

Day 4

今天的只要任务是完成首页。

Day 4,界面编写

我们的首页,使用的是Activity + Fragment搭配的方式,即一个MainActivity + MainFragment + BookmarksFragment的方式。其中,MainActivity的布局文件中包含了DrawerLayout, Toolbar以及Fragment所在的容器。

MainActivity对应布局文件如下:

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
    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">

    <include layout="@layout/app_bar_main" />

    <android.support.design.widget.NavigationView
        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" />

</android.support.v4.widget.DrawerLayout>

nav_header_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    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">

</LinearLayout>

nav_header实际上就只是一个简单的ImageView。

app_bar_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    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.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:elevation="0dp"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="@color/colorPrimary"
            app:popupTheme="@style/AppTheme.PopupOverlay" />

    </android.support.design.widget.AppBarLayout>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/layout_fragment"
        android:layout_marginTop="?actionBarSize"/>

</android.support.design.widget.CoordinatorLayout>

OK,Activity的布局文件完成。然后就可以写java代码了。

MainActivity.java

public class MainActivity extends AppCompatActivity
        implements NavigationView.OnNavigationItemSelectedListener{

    private MainFragment mainFragment;
    private BookmarksFragment bookmarksFragment;

    private NavigationView navigationView;
    private DrawerLayout drawer;
    private Toolbar toolbar;

    @Override
    protected void onCreate(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();
        }

        // 实例化BookmarksPresenter
        new BookmarksPresenter(MainActivity.this, bookmarksFragment);

        // 默认显示首页内容
        showMainFragment();

    }

    // 初始化控件
    private void initViews() {

        toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        drawer = (DrawerLayout) findViewById(R.id.drawer_layout);
        ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
                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并设置Title
    private void showMainFragment() {

        FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
        fragmentTransaction.show(mainFragment);
        fragmentTransaction.hide(bookmarksFragment);
        fragmentTransaction.commit();

        toolbar.setTitle(getResources().getString(R.string.app_name));

    }

    // 显示BookmarksFragment并设置Title
    private void showBookmarksFragment() {

        FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
        fragmentTransaction.show(bookmarksFragment);
        fragmentTransaction.hide(mainFragment);
        fragmentTransaction.commit();

        toolbar.setTitle(getResources().getString(R.string.nav_bookmarks));

    }

    @Override
    public boolean onNavigationItemSelected(@NonNull MenuItem item) {

        drawer.closeDrawer(GravityCompat.START);

        int id = item.getItemId();
        if (id == R.id.nav_home) {
            showMainFragment();
        } else if (id == R.id.nav_bookmarks) {
            showBookmarksFragment();
        } else if (id == R.id.nav_change_theme) {

        } else if (id == R.id.nav_settings) {

        } else if (id == R.id.nav_about) {

        }

        return true;
    }

    // 存储Fragment的状态
    @Override
    protected void onSaveInstanceState(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

public class MainFragment extends Fragment {

    private Context context;
    private MainPagerAdapter adapter;

    private TabLayout tabLayout;

    private ZhihuDailyFragment zhihuDailyFragment;
    private GuokrFragment guokrFragment;
    private DoubanMomentFragment doubanMomentFragment;

    private ZhihuDailyPresenter zhihuDailyPresenter;
    private GuokrPresenter guokrPresenter;
    private DoubanMomentPresenter doubanMomentPresenter;

    public MainFragment() {}

    public static MainFragment newInstance() {
        return new MainFragment();
    }

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);
    }

    @Override
    public void onCreate(@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 = new ZhihuDailyPresenter(context, zhihuDailyFragment);
        guokrPresenter = new GuokrPresenter(context, guokrFragment);
        doubanMomentPresenter = new DoubanMomentPresenter(context, doubanMomentFragment);

    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_main, container, false);

        // 初始化控件
        initViews(view);

        // 显示菜单
        setHasOptionsMenu(true);

        // 当tab layout位置为果壳精选时,隐藏fab
        tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
            @Override
            public void onTabSelected(TabLayout.Tab tab) {
                FloatingActionButton fab = (FloatingActionButton) getActivity().findViewById(R.id.fab);
                if (tab.getPosition() == 1) {
                    fab.hide();
                } else {
                    fab.show();
                }

            }

            @Override
            public void onTabUnselected(TabLayout.Tab tab) {

            }

            @Override
            public void onTabReselected(TabLayout.Tab tab) {

            }

        });

        return view;
    }


    // 初始化控件
    private void initViews(View view) {

        tabLayout = (TabLayout) view.findViewById(R.id.tab_layout);
        ViewPager viewPager = (ViewPager) view.findViewById(R.id.view_pager);
        // 设置离线数为3
        viewPager.setOffscreenPageLimit(3);

        adapter = new MainPagerAdapter(
                getChildFragmentManager(),
                context,
                zhihuDailyFragment,
                guokrFragment,
                doubanMomentFragment);

        viewPager.setAdapter(adapter);
        tabLayout.setupWithViewPager(viewPager);

    }

    @Override
    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
        super.onCreateOptionsMenu(menu, inflater);
        inflater.inflate(R.menu.main, menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        int id = item.getItemId();

        if (id == R.id.action_feel_lucky) {
            feelLucky();
        }
        return true;
    }

    // 保存状态
    @Override
    public void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        FragmentManager manager = getChildFragmentManager();
        manager.putFragment(outState, "zhihu", zhihuDailyFragment);
        manager.putFragment(outState, "guokr", guokrFragment);
        manager.putFragment(outState, "douban", doubanMomentFragment);
    }

    // 随便看看
    public void feelLucky() {
        Random random = new Random();
        int type = random.nextInt(3);
        switch (type) {
            case 0:
                zhihuDailyPresenter.feelLucky();
                break;
            case 1:
                guokrPresenter.feelLucky();
                break;
            default:
                doubanMomentPresenter.feelLucky();
                break;
        }
    }

    public MainPagerAdapter getAdapter() {
        return adapter;
    }
}

首页的MainFragment主要负责显示与TabLayout + ViewPager相关的内容。

OK,终于把首页的UI框架搭建好了,喝杯咖啡,休息一下,冷静冷静。

现在开始实现具体的ZhihuDailyFragment的布局。仔细观察,实际上,ZhihuDailyFragment所包含的控件就只有一个RecyclerView,将获取到的内容以列表的形式显示出来。并且,不难发现,果壳精选与豆瓣一刻的布局与知乎日报的列表布局相同,可以复用。

fragment_list.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.SwipeRefreshLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/refreshLayout">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:focusable="true"
        android:clickable="true">

        <android.support.v7.widget.RecyclerView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/recyclerView"
            android:scrollbars="vertical"
            android:scrollbarFadeDuration="1"
            android:fadeScrollbars="true"/>

    </FrameLayout>

</android.support.v4.widget.SwipeRefreshLayout>

布局实际上还包含了SwipeRefreshLayout,用于显示正在加载和手动刷新。

列表子项的布局有很多种,分别是:

  1. 普通仅文字
  2. 普通文字 + 图片
  3. 头部项,用于显示子项类型(如知乎日报,在收藏页面会用到)
  4. 底部项,加载更多等

home_list_item_without_image.xml - 普通仅文字

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    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">

    <TextView
        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" />

</android.support.v7.widget.CardView>

home_list_item_layout.xml - 普通文字 + 图片

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    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">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal"
        android:paddingLeft="8dp"
        android:paddingRight="8dp" >

        <TextView
            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" />

        <ImageView
            android:layout_width="80dp"
            android:layout_height="80dp"
            android:id="@+id/imageViewCover"
            android:layout_gravity="center_vertical" />

    </LinearLayout>

</android.support.v7.widget.CardView>

bookmark_header.xml - 头部项

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    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 - 底部项,加载更多

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    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.support.v4.widget.ContentLoadingProgressBar
        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" />

    <TextView
        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"/>

</LinearLayout>

布局文件到这里基本就完成了。

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

public class ZhihuDailyNews {

    private String date;
    private ArrayList<Question> stories;

    public String getDate() {
        return date;
    }

    public void setDate(String date) {
        this.date = date;
    }

    public ArrayList<Question> getStories() {
        return stories;
    }

    public void setStories(ArrayList<Question> stories) {
        this.stories = stories;
    }

    public class Question {

        private ArrayList<String> images;
        private int type;
        private int id;
        private String ga_prefix;
        private String title;

        public ArrayList<String> getImages() {
            return images;
        }

        public void setImages(ArrayList<String> images) {
            this.images = images;
        }

        public int getType() {
            return type;
        }

        public void setType(int type) {
            this.type = type;
        }

        public int getId() {
            return id;
        }

        public void setId(int id) {
            this.id = id;
        }

        public String getGa_prefix() {
            return ga_prefix;
        }

        public void setGa_prefix(String ga_prefix) {
            this.ga_prefix = ga_prefix;
        }

        public String getTitle() {
            return title;
        }

        public void setTitle(String title) {
            this.title = title;
        }

    }

}

Day 4,显示数据

首先,我们得有一个adapter。

ZhihuDailyNewsAdapter.java

public class ZhihuDailyNewsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {

    private final Context context;
    private final LayoutInflater inflater;
    private List<ZhihuDailyNews.Question> list = new ArrayList<ZhihuDailyNews.Question>();
    private OnRecyclerViewOnClickListener mListener;

    // 文字 + 图片
    private static final int TYPE_NORMAL = 0;
    // footer,加载更多
    private static final int TYPE_FOOTER = 1;

    public ZhihuDailyNewsAdapter(Context context, List<ZhihuDailyNews.Question> list){
        this.context = context;
        this.list = list;
        this.inflater = LayoutInflater.from(context);
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        // 根据ViewType加载不同布局
        switch (viewType) {
            case TYPE_NORMAL:
                return new NormalViewHolder(inflater.inflate(R.layout.home_list_item_layout, parent, false), mListener);
            case TYPE_FOOTER:
                return new FooterViewHolder(inflater.inflate(R.layout.list_footer, parent, false));
        }
        return null;
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {

        // 对不同的ViewHolder做不同的处理
        if (holder instanceof NormalViewHolder) {

            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
    @Override
    public int getItemCount() {
        return list.size() + 1;
    }

    @Override
    public int getItemViewType(int position) {
        if (position == list.size()) {
            return ZhihuDailyNewsAdapter.TYPE_FOOTER;
        }
        return ZhihuDailyNewsAdapter.TYPE_NORMAL;
    }

    public void setItemClickListener(OnRecyclerViewOnClickListener listener){
        this.mListener = listener;
    }

    public class NormalViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {

        private ImageView itemImg;
        private TextView tvLatestNewsTitle;
        private OnRecyclerViewOnClickListener listener;

        public NormalViewHolder(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);
        }

        @Override
        public void onClick(View v) {
            if (listener != null){
                listener.OnItemClick(v,getLayoutPosition());
            }
        }
    }

    public class FooterViewHolder extends RecyclerView.ViewHolder{

        public FooterViewHolder(View itemView) {
            super(itemView);
        }

    }

}

adapter中含有两个常量,TYPE_NORMAL,TYPE_FOOTER,用于区别item的类型,从而加载不同的布局。众所周知,RecyclerView原生并没有设置item点击事件的方法,所有我们需要自己定义一个接口--OnRecyclerViewOnClickListener

OnRecyclerViewOnClickListener.java

package com.marktony.zhihudaily.interfaze;

import android.view.View;

public interface OnRecyclerViewOnClickListener {

    void OnItemClick(View v,int position);

}

ZhihuDailyPresenter.java

实现ZhihuDailyPresenter中的loadPosts方法,记得要在manifest清单文件中添加网络访问权限:

model.load(Api.ZHIHU_HISTORY + formatter.ZhihuDailyDateFormat(date), new OnStringListener() {
                @Override
                public void onSuccess(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();
                }

                @Override
                public void onError(VolleyError error) {
                    view.stopLoading();
                    view.showError();
                }
            });

我们通过Gson,可以很简单将JSON格式数据转换为Java对象。

ZhihuDailyFragment

实现ZhihuDailyFragmentshowResults方法。

@Override
public void showResults(ArrayList<ZhihuDailyNews.Question> list) {
    if (adapter == null) {
        adapter = new ZhihuDailyNewsAdapter(getContext(), list);
        adapter.setItemClickListener(new OnRecyclerViewOnClickListener() {
            @Override
            public void OnItemClick(View v, int position) {
                presenter.startReading(position);
            }
        });
        recyclerView.setAdapter(adapter);
    } else {
        adapter.notifyDataSetChanged();
    }
}

Day 4,缓存内容

完成上面的代码,我们还只是实现了在有网络状态下的正常运行,如果用户并没有那么畅通无阻的网络连接呢?这个时候缓存就派上用场了,只要用户加载过一次,以后就算没有网络连接,用户也能查看之前已经离线的内容。我们选择使用Android原生SQLite数据库来存储数据(当然你也可以选择Realm)。

首先当然是要建立数据库了(由于纸飞机已经进行多个版本的迭代,所以你创建数据库的SQL语句或其他内容和我的文件应该不完全相同)。

DatabaseHelper.java

public class DatabaseHelper extends SQLiteOpenHelper {


    public DatabaseHelper(Context context, String name, SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
    }

    @Override
    public void onCreate(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");

    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

   }
}

相信大牛应该看出来了,这数据库设计的真心不怎么样😂,因为我数据库学的确实很一般。求大牛不喷。

字段 类型 含义 备注
id integer 主键 自增长
zhihu_id integer 知乎日报消息id 由知乎提供
zhihu_news text 知乎日报消息内容 与Java实体类对应
zhihu_time real 知乎日报消息发布的时间 由知乎提供
zhihu_content text 知乎日报消息详细内容 与Java实体类对应
bookmark integer 是否被收藏 由于SQLite并没有boolean类型,使用integer的不同值代替

OK,当我们正确请求到数据后,就可以进行存储了。

ZhihuDailyPresenter.java

if ( !queryIfIDExists(item.getId())) {
    db.beginTransaction();
    try {
        DateFormat format = new SimpleDateFormat("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();
    }
}

// 查询数据库表中是否已经存在了此id
private boolean queryIfIDExists(int id){

    Cursor cursor = db.query("Zhihu",null,null,null,null,null,null);
    if (cursor.moveToFirst()){
        do {
            if (id == cursor.getInt(cursor.getColumnIndex("zhihu_id"))){
                return true;
            }
        } while (cursor.moveToNext());
    }
    cursor.close();

    return false;
}

细心的童鞋可能发现了,诶,数据表中还有一个字段--zhihu_content,你没有存储呀。这是因为我们在请求知乎消息列表的时候,并没有返回消息的详细内容呀。不过详细内容我们还是需要缓存的,网络请求在UI线程上进行可能会引起ANR,那更好的解决办法就是在Service里面完成了。

我们先将一些必须的数据通过本地广播的形式,发送出去。

ZhihuDailyPresenter.java

Intent intent = new Intent("com.marktony.zhihudaily.LOCAL_BROADCAST");
intent.putExtra("type", CacheService.TYPE_ZHIHU);
intent.putExtra("id", item.getId());
LocalBroadcastManager.getInstance(context).sendBroadcast(intent);

然后在CacheService里接收广播,获取传送的数据,然后进行网络请求和数据存储。

CacheService.java

public class CacheService extends Service {

    private DatabaseHelper dbHelper;
    private SQLiteDatabase db;

    private static final String TAG = CacheService.class.getSimpleName();

    public static final int TYPE_ZHIHU = 0x00;
    public static final int TYPE_GUOKR = 0x01;
    public static final int TYPE_DOUBAN = 0x02;

    @Override
    public void onCreate() {
        super.onCreate();
        dbHelper = new DatabaseHelper(this, "History.db", null, 5);
        db = dbHelper.getWritableDatabase();

        IntentFilter filter = new IntentFilter();
        filter.addAction("com.marktony.zhihudaily.LOCAL_BROADCAST");
        LocalBroadcastManager manager = LocalBroadcastManager.getInstance(this);
        manager.registerReceiver(new LocalReceiver(), filter);

    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        return super.onStartCommand(intent, flags, startId);
    }

    @Override
    public boolean onUnbind(Intent intent) {
        return super.onUnbind(intent);
    }

    /**
     * 网络请求id对应的知乎日报的内容主体
     * 当type为0时,存储body中的数据
     * 当type为1时,再次请求share url中的内容并储存
     * @param id 所要获取的知乎日报消息内容对应的id
     */
    private void startZhihuCache(final int id) {

        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 = new StringRequest(Request.Method.GET, Api.ZHIHU_NEWS + id, new Response.Listener<String>() {
                        @Override
                        public void onResponse(String s) {
                            Gson gson = new Gson();
                            ZhihuDailyStory story = gson.fromJson(s, ZhihuDailyStory.class);
                            if (story.getType() == 1) {
                                StringRequest request = new StringRequest(Request.Method.GET, story.getShare_url(), new Response.Listener<String>() {
                                    @Override
                                    public void onResponse(String s) {
                                        ContentValues values = new ContentValues();
                                        values.put("zhihu_content", s);
                                        db.update("Zhihu", values, "zhihu_id = ?", new String[] {String.valueOf(id)});
                                        values.clear();
                                    }
                                }, new Response.ErrorListener() {
                                    @Override
                                    public void onErrorResponse(VolleyError volleyError) {

                                    }
                                });
                                request.setTag(TAG);
                                VolleySingleton.getVolleySingleton(CacheService.this).addToRequestQueue(request);
                            } else {
                                ContentValues values = new ContentValues();
                                values.put("zhihu_content", s);
                                db.update("Zhihu", values, "zhihu_id = ?", new String[] {String.valueOf(id)});
                                values.clear();
                            }

                        }
                    }, new Response.ErrorListener() {
                        @Override
                        public void onErrorResponse(VolleyError volleyError) {

                        }
                    });
                    request.setTag(TAG);
                    VolleySingleton.getVolleySingleton(CacheService.this).addToRequestQueue(request);
                }
            } while (cursor.moveToNext());
        }
        cursor.close();
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        VolleySingleton.getVolleySingleton(this).getRequestQueue().cancelAll(TAG);
    }

    class LocalReceiver extends BroadcastReceiver {

        @Override
        public void onReceive(Context context, Intent intent) {
            int id = intent.getIntExtra("id", 0);
            switch (intent.getIntExtra("type", -1)) {
                case TYPE_ZHIHU:
                    startZhihuCache(id);
                    break;
                case TYPE_GUOKR:
                    startGuokrCache(id);
                    break;
                case TYPE_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

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    initViews(); 

    // 启动服务
    startService(new Intent(this, CacheService.class));

}

@Override
protected void onDestroy() {
    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(new Intent(this, CacheService.class));
        }
    }
    super.onDestroy();
}

到这里,今天的内容就算结束了,内容是一周之中最多的一天,可能比前几天的总和还要多,可能需要你加班才能完全完成,之前Activity, Presenter, Fragment中各还有一部分内容没有完成,需要你自行补充完成。不过,看到自己的App正确的跑了起来,有木有很兴奋呢?休息休息,准备明天的工作吧。