如何用一周時間開發一款Android APP并在Google Play上線

fangjian 7年前發布 | 22K 次閱讀 安卓開發 Android開發 移動開發

目標:實現 紙飛機 App - 采用MVP架構,集合了知乎日報、果殼精選和豆瓣一刻的綜合性閱讀客戶端。效果圖如下所示:

本次教程分為7天,內容分別為:

  • 第一天,準備
    • 功能需求
    • 可行性分析
    • 其他準備
  • 第二天,UI
    • 選擇合適的UI
  • 第三天,整體架構
  • 第四天,首頁列表
    • 界面編寫
    • 實體類
    • 顯示數據
    • 緩存內容
  • 第五天,詳情頁與其他
    • 界面編寫
    • 實體類
    • 顯示數據
    • 設置與關于
  • 第六天,高級功能
    • 文章收藏
    • 夜間模式
    • 版本適配
  • 第七天,發布與開源
    • 在Google Play上線
    • 在GitHub開源
    • Q&A

好了,廢話不多說了。現在就開始吧。

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手機。
  • KX上網,確保能夠正常訪問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 導航模式的許可證 ,并且,我向你投來一個鄙視的眼神。
  • 使用正確的圖標 - 盡量使用 https://material.io/icons/ 網站上的圖標,如果你使用iOS版本的圖標,我再次向你投來一個鄙視的眼神。

紙飛機的最終設計效果如下:

如何用一周時間開發一款Android APP并在Google Play上線

首頁使用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。

  1. 首先創建最基本的BaseView和BasePresenter,他們分別是所有View和Presenter的基類。

    Baseview.java

    public interface BaseView<T> {
        // 為View設置Presenter
        void setPresenter(T presenter);
       // 初始化界面控件
        void initViews(View view);
    }

    BasePresenter.java

    public interface BasePresenter {
        // 獲取數據并改變界面顯示,在todo-mvp的項目中的調用時機為Fragment的OnResume()方法中
        void start();
    }
  2. 然后創建一個契約類,用于同一管理View和Presenter。這里以知乎日報的部分為例(如果沒有特別說明,后面的代碼均以知乎日報的部分為例,果殼精選與豆瓣一刻的代碼類似,詳細代碼可以在GitHub的repo中找到)。

    ZhihuDailyContract.java

    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。

    ZhihuDailyFragment.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() {
    
        }
    
    }

    ZhihuDailyPresenter.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() {
    
        }
    
    }

    然后完成果殼精選頁面,豆瓣一刻的內容,就可以進行下面的工作了。

  4. 創建VolleySingleton,即Volley的單例。這樣,整個應用就可以只維護一個請求隊列,加入新的網絡請求也會更加的方便。

    VolleySingleton.java

    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);
        }
    
    }
  5. 然后是Model層的實現。使用了Gson之后,對JSON的轉換更加方便了,所以,我們只需要返回類型為String即可。

    OnStringListener.java

    public interface OnStringListener {
        /**
         * 請求成功時回調
         * @param result
         */
        void onSuccess(String result);
        /**
         * 請求失敗時回調
         * @param error
         */
        void onError(VolleyError error);
    }

    定義了兩個方法,分別為請求成功時和請求失敗時的回調。

    然后定義一個StringModel的實現類–StringModelImpl。

    StringModelImpl.java

    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);
        }
    }
  6. 到這里,基本的架構就搭建完成了。現在可以喝杯咖啡,然后完成今天的最后一點工作,為后面的工作做準備。

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

    Api.java

    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或者是移動數據。

    NetworkState.java

    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 類型。

    DateFormatter.java

    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

實現 ZhihuDailyFragment 的 showResults 方法。

@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正確的跑了起來,有木有很興奮呢?休息休息,準備明天的工作吧。

DAY 5

今天的內容是顯示消息詳情內容。因為我們的消息內容實際上有三種類型,這里就不再重復。怎么區分呢?我的方法是定義了一個枚舉類型:

BeanType.java

public enum BeanType {

    TYPE_ZHIHU,TYPE_GUOKR,TYPE_DOUBAN;

}

這樣,我們就能根據不同的消息類型,獲取和加載不同的消息詳情內容了。

ZhihuDailyPresenter.java

@Override
public void startReading(int position) {

    context.startActivity(new Intent(context, DetailActivity.class)
            .putExtra("type", BeanType.TYPE_ZHIHU)
            .putExtra("id", list.get(position).getId())
            .putExtra("title", list.get(position).getTitle())
            .putExtra("coverUrl", list.get(position).getImages().get(0)));

}

Day 5,界面編寫

從知乎給我們的詳情內容為HTML格式來看,用WebView作為顯示控件最合適不過了(實際上果殼和豆瓣的詳情頁內容要么是返回了HTML格式,要么是直接給出了詳情頁的網頁地址,做簡單處理即可,可以實現復用)。

先看布局文件代碼:

universal_read_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    android:id="@+id/coordinatorLayout">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/app_bar_height"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="@color/colorPrimary"
            app:layout_scrollFlags="scroll|enterAlwaysCollapsed|enterAlways">

            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:id="@+id/image_view"
                android:fitsSystemWindows="true"
                android:scaleType="centerCrop"
                android:scrollbarStyle="insideInset"
                android:scrollbarAlwaysDrawVerticalTrack="true" />

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

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

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

    <android.support.v4.widget.SwipeRefreshLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        android:id="@+id/refreshLayout">

        <android.support.v4.widget.NestedScrollView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/scrollView"
            android:scrollbars="vertical"
            android:scrollbarFadeDuration="1"
            android:fadeScrollbars="true">

            <WebView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:id="@+id/web_view" />

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

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

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

將 ImageView 嵌入Toolbar中,然后搭配 CollapsingToolbarLayout 可收縮的ToolbarLayout,實現收縮和展開效果。 SwipeRefreshLayout 仍然用于顯示加載狀態和刷新。然后就齊活了。

接著是Activity。

DetailActivity.java

public class DetailActivity extends AppCompatActivity {

    private DetailFragment fragment;

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

        if (savedInstanceState != null) {
            fragment = (DetailFragment) getSupportFragmentManager().getFragment(savedInstanceState,"detailFragment");
        } else {
            fragment = new DetailFragment();
            getSupportFragmentManager().beginTransaction()
                    .replace(R.id.container, fragment)
                    .commit();
        }

        Intent intent = getIntent();

        DetailPresenter presenter = new DetailPresenter(DetailActivity.this, fragment);

        presenter.setType((BeanType) intent.getSerializableExtra("type"));
        presenter.setId(intent.getIntExtra("id", 0));
        presenter.setTitle(intent.getStringExtra("title"));
        presenter.setCoverUrl(intent.getStringExtra("coverUrl"));

    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        if (fragment.isAdded()) {
            getSupportFragmentManager().putFragment(outState, "detailFragment", fragment);
        }
    }
}

內容與首頁類似,相信你能理解,也是通過Activity + Fragment搭配的方式進行的。那么,View層也就是Fragment需要完成那些功能呢?我們可以直接在契約類中定義好。

DetailContract.java

public class DetailContract {

    interface View extends BaseView<Presenter> {

        // 顯示正在加載
        void showLoading();
        // 停止加載
        void stopLoading();
        // 顯示加載錯誤
        void showLoadingError();
        // 顯示分享時錯誤
        void showSharingError();
        // 正確獲取數據后顯示內容
        void showResult(String result);
        // 對于body字段的消息,直接接在url的內容
        void showResultWithoutBody(String url);
        // 設置頂部大圖
        void showCover(String url);
        // 設置標題
        void setTitle(String title);
        // 設置是否顯示圖片
        void setImageMode(boolean showImage);
        // 用戶選擇在瀏覽器中打開時,如果沒有安裝瀏覽器,顯示沒有找到瀏覽器錯誤
        void showBrowserNotFoundError();
        // 顯示已復制文字內容
        void showTextCopied();
        // 顯示文字復制失敗
        void showCopyTextError();
        // 顯示已添加至收藏夾
        void showAddedToBookmarks();
        // 顯示已從收藏夾中移除
        void showDeletedFromBookmarks();

    }

    interface Presenter extends BasePresenter{

        // 在瀏覽器中打開
        void openInBrowser();
        // 作為文字分享
        void shareAsText();
        // 打開文章中的鏈接
        void openUrl(WebView webView, String url);
        // 復制文字內容
        void copyText();
        // 復制文章鏈接
        void copyLink();
        // 添加至收藏夾或者從收藏夾中刪除
        void addToOrDeleteFromBookmarks();
        // 查詢是否已經被收藏了
        boolean queryIfIsBookmarked();
        // 請求數據
        void requestData();

    }

}

DetailFragment.java

public class DetailFragment extends Fragment
        implements DetailContract.View {

    public DetailFragment() {}

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }

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

        initViews(view);

        setHasOptionsMenu(true);

        return view;
    }

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

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        int id = item.getItemId();
        if (id == android.R.id.home) {
            getActivity().onBackPressed();
        } else if (id == R.id.action_more) {

        }
        return true;
    }

    @Override
    public void showLoading() {

    }

    @Override
    public void stopLoading() {

    }

    @Override
    public void showLoadingError() {

    }

    @Override
    public void showSharingError() {

    }

    @Override
    public void showResult(String result) {

    }

    @Override
    public void showResultWithoutBody(String url) {

    }

    @Override
    public void showCover(String url) {

    }

    @Override
    public void setTitle(String title) {

    }

    // WebView 提供了是否顯示圖片的方法
    @Override
    public void setImageMode(boolean showImage) {
        webView.getSettings().setBlockNetworkImage(showImage);
    }

    @Override
    public void showBrowserNotFoundError() {

    }

    @Override
    public void showTextCopied() {

    }

    @Override
    public void showCopyTextError() {

    }

    @Override
    public void showAddedToBookmarks() {

    }

    @Override
    public void showDeletedFromBookmarks() {

    }

    @Override
    public void setPresenter(DetailContract.Presenter presenter) {

    }

    @Override
    public void initViews(View view) {

    }

    // to change the title's font size of toolbar layout
    private void setCollapsingToolbarLayoutTitle(String title) {
        toolbarLayout.setTitle(title);
        toolbarLayout.setExpandedTitleTextAppearance(R.style.ExpandedAppBar);
        toolbarLayout.setCollapsedTitleTextAppearance(R.style.CollapsedAppBar);
        toolbarLayout.setExpandedTitleTextAppearance(R.style.ExpandedAppBarPlus1);
        toolbarLayout.setCollapsedTitleTextAppearance(R.style.CollapsedAppBarPlus1);
    }

}

Day 5,實體類

布局文件完成了,現在開始寫實體類。方法和昨天寫列表項實體類一樣,可以手動編寫,也可以用插件直接生成。直接放代碼。

JSON格式數據:

{
  "body": "HTML格式內容",
  "image_source": "《那些年,我們一起追的女孩》",
  "title": "瞎扯 · 如何正確地吐槽",
  "image": "http://pic1.zhimg.com/13ee386166c53553ea6997d821609e0c.jpg",
  "share_url": "http://daily.zhihu.com/story/9195072",
  "js": [],
  "ga_prefix": "020706",
  "section": {
    "thumbnail": "http://pic2.zhimg.com/1dc9cf1556c7b0b1527c18476698c5cd.jpg",
    "id": 2,
    "name": "瞎扯"
  },
  "images": [
    "http://pic2.zhimg.com/1dc9cf1556c7b0b1527c18476698c5cd.jpg"
  ],
  "type": 0,
  "id": 9195072,
  "css": [
    "http://news-at.zhihu.com/css/news_qa.auto.css?v=4b3e3"
  ]
}

對應的實體類: ZhihuDailyStory.java

public class ZhihuDailyStory {

    private String body;
    private String image_source;
    private String title;
    private String image;
    private String share_url;
    private ArrayList<String> js;
    private String ga_prefix;
    private ArrayList<String> images;
    private int type;
    private int id;
    private ArrayList<String> css;

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }

    public String getImage_source() {
        return image_source;
    }

    public void setImage_source(String image_source) {
        this.image_source = image_source;
    }

    public String getTitle() {
        return title;
    }

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

    public String getImage() {
        return image;
    }

    public void setImage(String image) {
        this.image = image;
    }

    public String getShare_url() {
        return share_url;
    }

    public void setShare_url(String share_url) {
        this.share_url = share_url;
    }

    public ArrayList<String> getJs() {
        return js;
    }

    public void setJs(ArrayList<String> js) {
        this.js = js;
    }

    public String getGa_prefix() {
        return ga_prefix;
    }

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

    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 ArrayList<String> getCss() {
        return css;
    }

    public void setCss(ArrayList<String> css) {
        this.css = css;
    }

}

Day 5,顯示數據

嘻嘻,首先當然是獲取數據了??,需要考慮網絡連接的情況,如果網絡通暢,則直接從網絡中獲取,否則去數據庫中獲取。

DetailPresenter.java

if (NetworkState.networkConnected(context)) {
    model.load(Api.ZHIHU_NEWS + id, new OnStringListener() {
        @Override
        public void onSuccess(String result) {
            {
                Gson gson = new Gson();
                try {
                    zhihuDailyStory = gson.fromJson(result, ZhihuDailyStory.class);
                    if (zhihuDailyStory.getBody() == null) {
                        view.showResultWithoutBody(zhihuDailyStory.getShare_url());
                    } else {
                        view.showResult(convertZhihuContent(zhihuDailyStory.getBody()));
                    }
                } catch (JsonSyntaxException e) {
                    view.showLoadingError();
                }
                view.stopLoading();
            }
        }

        @Override
        public void onError(VolleyError error) {
            view.stopLoading();
            view.showLoadingError();
        }
    });
} else {
    Cursor cursor = dbHelper.getReadableDatabase()
            .query("Zhihu", null, null, null, null, null, null);
    if (cursor.moveToFirst()) {
        do {
            if (cursor.getInt(cursor.getColumnIndex("zhihu_id")) == id) {
                String content = cursor.getString(cursor.getColumnIndex("zhihu_content"));
                try {
                    zhihuDailyStory = gson.fromJson(content, ZhihuDailyStory.class);
                } catch (JsonSyntaxException e) {
                    view.showResult(content);
                }
                view.showResult(convertZhihuContent(zhihuDailyStory.getBody()));
            }
        } while (cursor.moveToNext());
    }
    cursor.close();
}

private String convertZhihuContent(String preResult) {

    preResult = preResult.replace("<div class="img-place-holder">", "");
    preResult = preResult.replace("<div class="headline">", "");

    // 在api中,css的地址是以一個數組的形式給出,這里需要設置
    // api中還有js的部分,這里不再解析js
    // 不再選擇加載網絡css,而是加載本地assets文件夾中的css
    String css = "<link rel="stylesheet" href="file:///android_asset/zhihu_daily.css" type="text/css">";

    String theme = "<body className="" onload="onLoaded()">";

    return new StringBuilder()
            .append("<!DOCTYPE html>n")
            .append("<html lang="en" xmlns="http://www.w3.org/1999/xhtml">n")
            .append("<head>n")
            .append("t<meta charset="utf-8" />")
            .append(css)
            .append("n</head>n")
            .append(theme)
            .append(preResult)
            .append("</body></html>").toString();
}

對獲取的數據進行一下拼接,組成一個完整的HTML頁面的內容。需要注意的是CSS文件,它負責整個HTML的樣式,可以在 這里 查看整個CSS文件的內容或下載CSS文件。

最后的顯示就非常簡單了:

DetailFragment.java

@Override
public void showResult(String result) {
    webView.loadDataWithBaseURL("x-data://base",result,"text/html","utf-8",null);
}

至此,最基本的顯示詳情內容的部分就已經完成了。實際上,我們還有很多的工作細微的工作沒有完成,喝杯咖啡,休息一下,再回來繼續吧。

Day 5,設置與關于

設置與關于也和首頁及詳情相同,采用的是Activity + Fragment搭配的形式。不過,這里的Fragment并不是我們前面所見到的 android.support.v4.app.Fragment 下的Fragment,而是 android.support.v7.preference.PreferenceFragmentCompat 。通過 PreferenceFragmentCompat ,我們可以很快的實現設置與關于頁面。(由于二者的實現方法類似,我就以實現關于頁面為例)

首先,我們需要在 res 目錄下新建 xml 文件夾,新建 about_preference_fragment.xml 文件,作為設置和關于頁面的布局文件。

about_preference_fragment.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.preference.PreferenceScreen
    xmlns:android="http://schemas.android.com/apk/res/android">

    <android.support.v7.preference.PreferenceCategory android:title="@string/app_name">

        <android.support.v7.preference.Preference android:title="@string/version" />

        <android.support.v7.preference.Preference android:title="@string/rate"
            android:key="rate"
            android:summary="@string/rate_description" />

    </android.support.v7.preference.PreferenceCategory>

    <android.support.v7.preference.PreferenceCategory android:title="@string/author">

        <android.support.v7.preference.Preference android:title="@string/author_name"
            android:key="author"
            android:summary="@string/author_description"/>

        <android.support.v7.preference.Preference
            android:title="@string/follow_me_on_github"
            android:key="follow_me_on_github"
            android:summary="@string/github_url"/>

        <android.support.v7.preference.Preference
            android:title="@string/follow_me_on_zhihu"
            android:key="follow_me_on_zhihu"
            android:summary="@string/zhihu_account"/>

    </android.support.v7.preference.PreferenceCategory>

    <android.support.v7.preference.PreferenceCategory android:title="@string/support">

        <android.support.v7.preference.Preference android:title="@string/feedback"
            android:key="feedback"
            android:summary="@string/feedback_description"/>

        <android.support.v7.preference.Preference android:title="@string/coffee"
            android:key="coffee"
            android:summary="@string/coffee_description"/>

        <android.support.v7.preference.Preference android:title="@string/open_source_license"
            android:key="open_source_license" />

    </android.support.v7.preference.PreferenceCategory>

</android.support.v7.preference.PreferenceScreen>

這樣,布局文件就已經完成了。接下來是和首頁等類似的,分別完成 Contract , Activity , Fragment , Presenter 。

AboutContract.java

public interface AboutContract {

    interface View extends BaseView<Presenter>{

        // 如果用戶設備沒有安裝商店應用,提示此錯誤
        void showRateError();
        // 如果用戶設備沒有安裝郵件應用,提示此錯誤
        void showFeedbackError();
        // 如果用戶沒有安裝瀏覽器,提示此錯誤
        void showBrowserNotFoundError();

    }

    interface Presenter extends BasePresenter {
        // 在應用商店中評分
        void rate();
        // 展示開源許可頁
        void openLicense();
        // 在GitHub上關注我
        void followOnGithub();
        // 在知乎上關注我
        void followOnZhihu();
        // 通過郵件反饋
        void feedback();
        // 捐贈
        void donate();
        // 顯示小彩蛋
        void showEasterEgg();

    }

}

AboutPreferenceActivity.java

public class AboutPreferenceActivity extends AppCompatActivity {

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

        initViews();

        AboutPreferenceFragment fragment = new AboutPreferenceFragment();

        getSupportFragmentManager()
                .beginTransaction()
                .add(R.id.about_container,fragment)
                .commit();

        new AboutPresenter(AboutPreferenceActivity.this, fragment);

    }

    private void initViews() {
        setSupportActionBar((Toolbar) findViewById(R.id.toolbar));
        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == android.R.id.home){
            onBackPressed();
        }
        return super.onOptionsItemSelected(item);
    }

}

是不是有種似曾相識的感覺呢??

AboutPreferenceFragment.java

public class AboutPreferenceFragment extends PreferenceFragmentCompat
        implements AboutContract.View {

    private Toolbar toolbar;
    private AboutContract.Presenter presenter;

    public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {

        addPreferencesFromResource(R.xml.about_preference_fragment);

        initViews(getView());

        findPreference("rate").setOnPreferenceClickListener(new android.support.v7.preference.Preference.OnPreferenceClickListener() {
            @Override
            public boolean onPreferenceClick(android.support.v7.preference.Preference preference) {
                presenter.rate();
                return false;
            }
        });

        findPreference("open_source_license").setOnPreferenceClickListener(new android.support.v7.preference.Preference.OnPreferenceClickListener() {
            @Override
            public boolean onPreferenceClick(android.support.v7.preference.Preference preference) {
                return false;
            }
        });

        findPreference("follow_me_on_github").setOnPreferenceClickListener(new android.support.v7.preference.Preference.OnPreferenceClickListener() {
            @Override
            public boolean onPreferenceClick(android.support.v7.preference.Preference preference) {
                return false;
            }
        });

        findPreference("follow_me_on_zhihu").setOnPreferenceClickListener(new android.support.v7.preference.Preference.OnPreferenceClickListener() {
            @Override
            public boolean onPreferenceClick(android.support.v7.preference.Preference preference) {
                return false;
            }
        });

        findPreference("feedback").setOnPreferenceClickListener(new android.support.v7.preference.Preference.OnPreferenceClickListener() {
            @Override
            public boolean onPreferenceClick(android.support.v7.preference.Preference preference) {
                return false;
            }
        });

        findPreference("coffee").setOnPreferenceClickListener(new android.support.v7.preference.Preference.OnPreferenceClickListener() {
            @Override
            public boolean onPreferenceClick(android.support.v7.preference.Preference preference) {
                return false;
            }
        });

        findPreference("author").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
            @Override
            public boolean onPreferenceClick(Preference preference) {
                return false;
            }
        });

    }

    @Override
    public void onResume() {
        super.onResume();
        presenter.start();
    }

    @Override
    public void setPresenter(AboutContract.Presenter presenter) {
        if (presenter != null){
            this.presenter = presenter;
        }
    }

    @Override
    public void initViews(View view) {

    }

    @Override
    public void showRateError() {

    }

    @Override
    public void showFeedbackError() {

    }

    @Override
    public void showBrowserNotFoundError() {

    }

}

不知道你主要到沒有,這里有一些代碼和我們常見的有所不同。例如, onCreatePreferences 方法, addPreferencesFromResource 方法等。

AboutPresenter.java

public class AboutPresenter implements AboutContract.Presenter {

    public AboutPresenter(AppCompatActivity activity, AboutContract.View view) {

    }

    @Override
    public void start() {

    }

    @Override
    public void rate() {
        try {
            Uri uri = Uri.parse("market://details?id=" + activity.getPackageName());
            Intent intent = new Intent(Intent.ACTION_VIEW,uri);
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            activity.startActivity(intent);
        } catch (android.content.ActivityNotFoundException ex){
            view.showRateError();
        }

    }

    @Override
    public void openLicense() {
        activity.startActivity(new Intent(activity,OpenSourceLicenseActivity.class));
    }

   @Override
    public void followOnGithub() {
        if (sp.getBoolean("in_app_browser",true)){
            CustomTabActivityHelper.openCustomTab(
                    activity,
                    customTabsIntent.build(),
                    Uri.parse(activity.getString(R.string.github_url)),
                    new CustomFallback() {
                        @Override
                        public void openUri(Activity activity, Uri uri) {
                            super.openUri(activity, uri);
                        }
                    });
        } else {
            try{
                activity.startActivity(new Intent(Intent.ACTION_VIEW).setData(Uri.parse( activity.getString(R.string.github_url))));
            } catch (android.content.ActivityNotFoundException ex){
                view.showBrowserNotFoundError();
            }
        }
    }

    @Override
    public void followOnZhihu() {

    }

    @Override
    public void feedback() {
        try{
            Uri uri = Uri.parse(activity.getString(R.string.sendto));
            Intent intent = new Intent(Intent.ACTION_SENDTO,uri);
            intent.putExtra(Intent.EXTRA_SUBJECT, activity.getString(R.string.mail_topic));
            intent.putExtra(Intent.EXTRA_TEXT,
                    activity.getString(R.string.device_model) + Build.MODEL + "n"
                            + activity.getString(R.string.sdk_version) + Build.VERSION.RELEASE + "n"
                            + activity.getString(R.string.version));
            activity.startActivity(intent);
        }catch (android.content.ActivityNotFoundException ex){
            view.showFeedbackError();
        }
    }

    @Override
    public void donate() {

    }

    @Override
    public void showEasterEgg() {

    }

}

具體的實現邏輯我沒有給出,你可以在源代碼中找到。需要注意的一些小細節,例如,在反饋操作中,我們是通過調用郵件App實現的。如下:

@Override
public void feedback() {
    try{
        Uri uri = Uri.parse(activity.getString(R.string.sendto));
        Intent intent = new Intent(Intent.ACTION_SENDTO,uri);
        intent.putExtra(Intent.EXTRA_SUBJECT, activity.getString(R.string.mail_topic));
        intent.putExtra(Intent.EXTRA_TEXT,
                activity.getString(R.string.device_model) + Build.MODEL + "n"
                        + activity.getString(R.string.sdk_version) + Build.VERSION.RELEASE + "n"
                        + activity.getString(R.string.version));
        activity.startActivity(intent);
    }catch (android.content.ActivityNotFoundException ex){
        view.showFeedbackError();
    }
}

為毛要做try...catch的操作呢?難道用戶的設備上會連郵件App都沒有安裝嗎?當然會。有的設備上甚至連瀏覽器都沒有安裝,所以,try...catch還是很有必要的。

需要提起的是,設置頁面,我們需要對用戶的偏好進行存儲,然后在需要的地方獲取這個值就好了,而PreferenceScreen本身就具有這樣的功能,不再需要額外的SharedPreference去存儲。在我的代碼你可能會看到這樣的情況,這是因為我并不是在項目的最初就引進了 PreferenceScreen ,當時就是直接用不同的控件搭配成的設置界面,用 SharedPreference 存儲信息,后來引入了支持庫之后,為了不破壞用戶的體驗(例如,某次版本升級直接導致之前的設置偏好全部失效),堅持使用了這樣一個‘多此一舉’的方法。

至此,今天的工作就完成的差不多了,好好休息一下,工作最多的兩天已經過去了。冬天過去了,春天還會遠嗎?在正式結束今天的工作之前,請先看一下 DAY 7在Google Play上線 第一小節的內容,我們有一項任務需要完成--注冊Google Play開發者賬號,因為GP對開發者賬號的審核48小時(實際體驗不需要那么久,大概24小時左右,看人品羅),所以,咱們先做好準備工作吧。

DAY 6

終于來到了Day 6,還有一天就要完成此次教程了。加油!

Day 6,文章收藏

我們在之前設計數據庫時,就在表中插入了一個 bookmark 字段,用于標示當前一行是否被收藏。我們先看看如何添加收藏和取消收藏。

DetailPresenter.java

if (queryIfIsBookmarked()) {
    // delete
    // update Zhihu set bookmark = 0 where zhihu_id = id
    ContentValues values = new ContentValues();
    values.put("bookmark", 0);
    dbHelper.getWritableDatabase().update(tmpTable, values, tmpId + " = ?", new String[]{String.valueOf(id)});
    values.clear();
} else {
    // add
    // update Zhihu set bookmark = 1 where zhihu_id = id
    ContentValues values = new ContentValues();
    values.put("bookmark", 1);
    dbHelper.getWritableDatabase().update(tmpTable, values, tmpId + " = ?", new String[]{String.valueOf(id)});
    values.clear();
}

那么如果在收藏頁面中展示出來呢?套路,仍然是之前的套路。還是熟悉的配方,還是原來的味道。

布局文件也就是簡單的一個列表,代碼和之前首頁的代碼相同,不再寫出。

BookmarksContract.java

public interface BookmarksContract {

    interface View extends BaseView<Presenter> {

        // 顯示結果
        void showResults(ArrayList<ZhihuDailyNews.Question> zhihuList,
                         ArrayList<GuokrHandpickNews.result> guokrList,
                         ArrayList<DoubanMomentNews.posts> doubanList,
                         ArrayList<Integer> types);

        // 提示數據變化
        void notifyDataChanged();

        // 顯示正在加載
        void showLoading();

        // 停止加載
        void stopLoading();

    }

    interface Presenter extends BasePresenter {

        // 請求結果
        void loadResults(boolean refresh);

        // 跳轉到詳情頁面
        void startReading(BeanType type, int position);

        // 請求新數據
        void checkForFreshData();

        // 隨便看看
        void feelLucky();

    }

}

BookmarksFragment.java

public class BookmarksFragment extends Fragment
        implements BookmarksContract.View {

    private RecyclerView recyclerView;
    private SwipeRefreshLayout refreshLayout;
    private BookmarksAdapter adapter;
    private BookmarksContract.Presenter presenter;

    public BookmarksFragment() {}

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

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }

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

        initViews(view);

        setHasOptionsMenu(true);

        presenter.loadResults(false);

        refreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
            @Override
            public void onRefresh() {
                presenter.loadResults(true);
            }
        });

        return view;
    }

    @Override
    public void setPresenter(BookmarksContract.Presenter presenter) {
        if (presenter != null) {
            this.presenter = presenter;
        }
    }

    @Override
    public void initViews(View view) {

        recyclerView = (RecyclerView) view.findViewById(R.id.recyclerView);
        recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));

        refreshLayout = (SwipeRefreshLayout) view.findViewById(R.id.refreshLayout);
        refreshLayout.setColorSchemeResources(R.color.colorPrimary);

    }

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

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        int id = item.getItemId();
        if (id == R.id.action_search) {
            startActivity(new Intent(getActivity(), SearchActivity.class));
        } else if (id == R.id.action_feel_lucky) {
            presenter.feelLucky();
        }
        return true;
    }

    @Override
    public void showResults(ArrayList<ZhihuDailyNews.Question> zhihuList,
                            ArrayList<GuokrHandpickNews.result> guokrList,
                            ArrayList<DoubanMomentNews.posts> doubanList,
                            ArrayList<Integer> types) {

        if (adapter == null) {

            adapter = new BookmarksAdapter(getActivity(), zhihuList, guokrList, doubanList, types);
            adapter.setItemListener(new OnRecyclerViewOnClickListener() {
                @Override
                public void OnItemClick(View v, int position) {
                    int type = recyclerView.findViewHolderForLayoutPosition(position).getItemViewType();
                    if (type == BookmarksAdapter.TYPE_ZHIHU_NORMAL) {
                        presenter.startReading(BeanType.TYPE_ZHIHU, position);
                    } else if (type == BookmarksAdapter.TYPE_GUOKR_NORMAL) {
                        presenter.startReading(BeanType.TYPE_GUOKR, position);
                    } else if (type == BookmarksAdapter.TYPE_DOUBAN_NORMAL) {
                        presenter.startReading(BeanType.TYPE_DOUBAN, position);
                    }
                }
            });
            recyclerView.setAdapter(adapter);
        } else {
            adapter.notifyDataSetChanged();
        }

    }

    @Override
    public void notifyDataChanged() {
        presenter.loadResults(true);
        adapter.notifyDataSetChanged();
    }

    @Override
    public void showLoading() {
        refreshLayout.setRefreshing(true);
    }

    @Override
    public void stopLoading() {
        refreshLayout.setRefreshing(false);
    }

}

BookmarksPresenter.java

public class BookmarksPresenter implements BookmarksContract.Presenter {

    private BookmarksContract.View view;
    private Context context;
    private Gson gson;

    private ArrayList<DoubanMomentNews.posts> doubanList;
    private ArrayList<GuokrHandpickNews.result> guokrList;
    private ArrayList<ZhihuDailyNews.Question> zhihuList;

    private ArrayList<Integer> types;

    private DatabaseHelper dbHelper;
    private SQLiteDatabase db;

    public BookmarksPresenter(Context context, BookmarksContract.View view) {
        this.context = context;
        this.view = view;
        this.view.setPresenter(this);
        gson = new Gson();
        dbHelper = new DatabaseHelper(context, "History.db", null, 5);
        db = dbHelper.getWritableDatabase();

        zhihuList = new ArrayList<>();
        guokrList = new ArrayList<>();
        doubanList = new ArrayList<>();

        types = new ArrayList<>();

    }

    @Override
    public void start() {

    }

    @Override
    public void loadResults(boolean refresh) {

        if (!refresh) {
            view.showLoading();
        } else {
            zhihuList.clear();
            guokrList.clear();
            doubanList.clear();
            types.clear();
        }

        checkForFreshData();

        view.showResults(zhihuList, guokrList, doubanList, types);

        view.stopLoading();

    }

    @Override
    public void startReading(BeanType type, int position) {
        Intent intent = new Intent(context, DetailActivity.class);
        switch (type) {
            case TYPE_ZHIHU:
                ZhihuDailyNews.Question q = zhihuList.get(position - 1);
                intent.putExtra("type", BeanType.TYPE_ZHIHU);
                intent.putExtra("id",q.getId());
                intent.putExtra("title", q.getTitle());
                intent.putExtra("coverUrl", q.getImages().get(0));
                break;

            case TYPE_GUOKR:
                GuokrHandpickNews.result r = guokrList.get(position - zhihuList.size() - 2);
                intent.putExtra("type", BeanType.TYPE_GUOKR);
                intent.putExtra("id", r.getId());
                intent.putExtra("title", r.getTitle());
                intent.putExtra("coverUrl", r.getHeadline_img());
                break;
            case TYPE_DOUBAN:
                DoubanMomentNews.posts p = doubanList.get(position - zhihuList.size() - guokrList.size() - 3);
                intent.putExtra("type", BeanType.TYPE_DOUBAN);
                intent.putExtra("id", p.getId());
                intent.putExtra("title", p.getTitle());
                if (p.getThumbs().size() == 0){
                    intent.putExtra("coverUrl", "");
                } else {
                    intent.putExtra("image", p.getThumbs().get(0).getMedium().getUrl());
                }
                break;
            default:
                break;
        }
        context.startActivity(intent);
    }

    @Override
    public void checkForFreshData() {

        // every first one of the 3 lists is with header
        // add them in advance

        types.add(TYPE_ZHIHU_WITH_HEADER);
        Cursor cursor = db.rawQuery("select * from Zhihu where bookmark = ?", new String[]{"1"});
        if (cursor.moveToFirst()) {
            do {
                ZhihuDailyNews.Question question = gson.fromJson(cursor.getString(cursor.getColumnIndex("zhihu_news")), ZhihuDailyNews.Question.class);
                zhihuList.add(question);
                types.add(TYPE_ZHIHU_NORMAL);
            } while (cursor.moveToNext());
        }

        types.add(TYPE_GUOKR_WITH_HEADER);
        cursor = db.rawQuery("select * from Guokr where bookmark = ?", new String[]{"1"});
        if (cursor.moveToFirst()) {
            do {
                GuokrHandpickNews.result result = gson.fromJson(cursor.getString(cursor.getColumnIndex("guokr_news")), GuokrHandpickNews.result.class);
                guokrList.add(result);
                types.add(TYPE_GUOKR_NORMAL);
            } while (cursor.moveToNext());
        }

        types.add(TYPE_DOUBAN_WITH_HEADER);
        cursor = db.rawQuery("select * from Douban where bookmark = ?", new String[]{"1"});
        if (cursor.moveToFirst()) {
            do {
                DoubanMomentNews.posts post = gson.fromJson(cursor.getString(cursor.getColumnIndex("douban_news")), DoubanMomentNews.posts.class);
                doubanList.add(post);
                types.add(TYPE_DOUBAN_NORMAL);
            } while (cursor.moveToNext());
        }

        cursor.close();

    }

    @Override
    public void feelLucky() {
        Random random = new Random();
        int p = random.nextInt(types.size());
        while (true) {
            if (types.get(p) == BookmarksAdapter.TYPE_ZHIHU_NORMAL) {
                startReading(BeanType.TYPE_ZHIHU, p);
                break;
            } else if (types.get(p) == BookmarksAdapter.TYPE_GUOKR_NORMAL) {
                startReading(BeanType.TYPE_GUOKR, p);
                break;
            } else if (types.get(p) == BookmarksAdapter.TYPE_DOUBAN_NORMAL) {
                startReading(BeanType.TYPE_DOUBAN, p);
                break;
            } else {
                p = random.nextInt(types.size());
            }
        }
    }

}

樂,用戶可以從收藏列表頁面查看內容詳情,可能在這里,用戶取消了收藏,而我們就需要及時地刷新界面數據,否則就會影響用戶的體驗。

Day 6,夜間模式

關于夜間模式的實現,請查看文章 簡潔優雅地實現夜間模式 或者 這里

Day 6,版本適配

事實上,版本適配的范圍非常的廣泛。例如,多語言適配,高低Android版本的適配,還有對多屏幕的適配,特殊用戶的適配等等。

  1. 多語言適配

    在 res 目錄下新建不同的 values 目錄,例如,需要適配英語就新建 values-en 目錄,簡體中文 values-zh-rCN ,繁體中文(臺灣) values-zh-rTW ,繁體中文(香港) values-zh-rHK ,等等。(這些代碼不區分大小寫;r 前綴用于區分區域碼。 不能單獨指定區域。)

    .
    ├── app
    ├──├── res
    ├──  ── ├── values
    ├──  ──  ── ├── strings.xml
    ├──  ── ├── values-en
    ├──  ──  ── ├── strings.xml
    ├──  ── ├── values-zh-rCN
    ├──  ──  ── ├── strings.xml
    ├──  ── ├── values-zh-rHK
    ├──  ──  ── ├── strings.xml

    更多內容請點擊 這里

  2. 高低Android版本適配

    關于Android碎片化的討論已經非常多了,這里我就不再鞭尸了。只簡單的舉兩個例子。在Android 4.1 Jelly Bean系統的華為手機上,系統的DatePicker的樣式是這樣的。

    很明顯,這和我們Material Design的設計語言很違和,使用開源庫 materialdatetimepicker 就可以在這樣的低版本的設備上實現MD版本的Date Picker Dialog。實現UI的統一對提升用戶體驗還是很有幫助的。

    對于高版本,例如Android 7.x Nougat,有很多的新特性,例如新的通知欄,Shortcuts等等,雖然用上最新系統的用戶量可能不大,但是當這些用戶看到我們的應用適配了這些新特性時,應該也會感覺到眼前一亮吧,起到了錦上添花的作用。

  3. 支持多種屏幕

    和多語言適配類似,我們也可以通過提供不同資源文件的方式實現適配。例如,以下應用資源目錄 為不同屏幕尺寸和不同可繪制對象提供不同的布局設計。使用 mipmap/ 文件夾放置 啟動器圖標。

    res/layout/my_layout.xml              // layout for normal screen size ("default")
    res/layout-large/my_layout.xml        // layout for large screen size
    res/layout-xlarge/my_layout.xml       // layout for extra-large screen size
    res/layout-xlarge-land/my_layout.xml  // layout for extra-large in landscape orientation
    
    res/drawable-mdpi/graphic.png         // bitmap for medium-density
    res/drawable-hdpi/graphic.png         // bitmap for high-density
    res/drawable-xhdpi/graphic.png        // bitmap for extra-high-density
    res/drawable-xxhdpi/graphic.png       // bitmap for extra-extra-high-density
    
    res/mipmap-mdpi/my_icon.png         // launcher icon for medium-density
    res/mipmap-hdpi/my_icon.png         // launcher icon for high-density
    res/mipmap-xhdpi/my_icon.png        // launcher icon for extra-high-density
    res/mipmap-xxhdpi/my_icon.png       // launcher icon for extra-extra-high-density
    res/mipmap-xxxhdpi/my_icon.png      // launcher icon for extra-extra-extra-high-density

    更多內容請點擊 這里

  4. 其他適配

    例如, 提升體驗-支持Chrome Custom Tabs

哈,做完今天的工作,編碼的工作就完成的差不多了。好好的睡一覺,準備明天的工作。

DAY 7

Day 7,在Google Play上線

  1. 注冊Google Play開發者賬號

    工具準備:

    • KX上網,你懂的
    • Chrome瀏覽器或Firefox瀏覽器
    • $25,25刀的注冊費用
    • 支持國際支付功能(VISA, Master等)的信用卡,便于支付25刀的注冊費

    好了,我們現在開始正式的搞事情。

    1. 注冊Google賬號

      如果你已經有了Google賬號,就直接跳過這一小步吧。

      我們先去 https://accounts.google.com/SignUp 注冊賬號。按照自身的信息填寫即可。

    2. 登錄開發者后臺

      登錄 https://play.google.com/apps/publish/signup/ 。

      勾選同意并點擊繼續付款。需要注意的是,我們要先進到付款頁面,然后再綁定Google Wallet。否則的話,就不能保證付款成功了。

    3. 付款

      點擊添加新的付款方式,一路按提示輸入即可(由于我之前已經注冊過了,這里盜用一下被人的圖,原作者請不要打我?)。

      如果綁定成功,Google可能會先從信用卡中扣除$1進行授權。

    4. 審核

      Google最多需要48小時進行審核。我們可以通過 Google Wallet 查看該訂單的支付狀態。如果顯示 已完成 ,就說明GP賬號申請成功了。

    5. 沒有信用卡怎么辦?

      相信有很多像我一樣的學生黨,沒有信用卡或者信用卡不支持國際支付功能,該怎么解決呢?這個時候,就是萬能的某寶發揮作用的時候了。有一種信用卡叫做 虛擬信用卡 ,我們可以通過向虛擬信用卡充值,然后用這樣的卡去支付那25刀。具體的地址等咨詢店小二即可。如果你覺得這樣的方法太繁瑣,或者我有錢任性,那么直接在馬爸爸的網站上直接買一個開發者賬號吧,不過一般情況下,費用肯定是高于25美刀的,而且安全性也值得檢驗(如果你打算買賬號,那么務必在拿到賬號之后第一時間修改密碼和認證信息等等)。

    通過上面的步驟,我們申請到的賬號還只能發布免費的應用。如果對應用進行收費,你可以查看控制臺中 財務報表 ,獲取更多商家賬戶的信息。

    更多信息,請點擊 這里

  2. 有了賬號,我們就需要生成APK文件了。

    在保證項目正確運行的情況下(記得更換應用圖標),我們點擊菜單項中的 Build --> Generate Signed APK... ,對APK進行簽名。

    選擇生成APK的Module。

    這時候需要我們選擇key,用于對APK簽名。

    Key的作用是為了保證每個應用程序開發商合法ID,防止部分開放商可能通過使用相同的Package Name來混淆替換已經安裝的程序,我們需要對我們發布的APK文件進行唯一簽名,保證我們每次發布的版本的一致性(如自動更新不會因為版本不一致而無法安裝)。

    如果沒有Key,我們就需要創建一個。選擇 Create new... 創建。

    各種信息對應如下:

    名稱 描述
    Key store path key的存儲路徑
    Password key的密碼
    Confirm 確認密碼
    Alias 別名
    Validity(years) 有效期限(年)
    First and Last Name 姓名
    Organizational Unit 組織單位
    Organization 組織
    City of Location 所在城市
    State or Province
    Country Code(XX) 國家代碼

    填寫完信息后,點擊OK生成。這里生成的key一定要妥善保管,以后我們對應用進行版本更新時,需要用到。

    新建成功后,我們選擇剛剛生成的key,輸入密碼,點擊 Next --> Finish 。

  3. 上傳應用

    現在我們就可以把應用上傳到Google Play了。

    3.1. 添加APK

    • 3.1.1. 轉到 Google Play Developer Console

    • 3.1.2. 依次選擇 所有應用 --> + 創建應用 。

    • 3.1.3 使用下拉菜單選擇默認語言,并為您的應用添加標題。輸入您想要在 Google Play 中顯示的應用名稱。

    • 3.1.4 選擇上傳 APK。

    3.2. 設置商品詳情

    我們需要為我們的應用設置 商品詳情 , 圖片資源 , 語言和翻譯 , 分類 , 詳細聯系信息 , 隱私權政策 ,等。對于程序員來說,最困難的應該就是各種圖片了吧,在沒有設計師的情況下,就讓我們程序員發揮靈魂畫師的功力吧,哈哈?。

    3.3. 后續步驟

    我們還需要完成的步驟有:

    • 填寫應用的內容分級問卷
    • 了解如何將應用發布到不同的國家/地區以及Android計劃
    • 使用標準或定時發布應用
    • 通過實驗優化商品詳情

    更多信息,請點擊 這里

    哈,到這里,應用上傳就完成了,現在等待應用發布審核成功就好了。

Day 7,在GitHub開源

  1. 注冊GitHub

    GitHub 是一個

    同性交友社區

    面向開源及私有軟件項目的托管平臺,作為開源代碼庫以及版本控制系統,Github擁有超過900萬開發者用戶。隨著越來越多的應用程序轉移到了云上,Github已經成為了管理軟件開發以及發現已有代碼的首選方法。

    我們先注冊賬號,地址為: https://github.com 。

    賬號注冊成功后,進入

    GayHub

    GitHub 個人信息頁,大概是這個樣子的。

    第一步的工作就完成了。

  2. 安裝Git

    Git 是一款免費、開源的分布式版本控制系統,用于敏捷高效地處理任何或小或大的項目。官網的介紹是這樣的:

    Git is a free and open source distributed version control system designed to handle everything from small to very large projects with speed and efficiency.

    下載Git,地址為 https://git-scm.com/downloads ,下載對應版本即可。

    • 在macOS上,在 Mac 上安裝 Git 有多種方式。
      • 最簡單的方法是安裝 Xcode Command Line Tools。 Mavericks (10.9) 或更高版本的系統中,在 Terminal 里嘗試首次運行 git 命令即可。 如果沒有安裝過命令行開發者工具,將會提示你安裝。
      • 如果你想安裝更新的版本,可以使用二進制安裝程序。 官方維護的 OSX Git 安裝程序可以在 Git 官方網站下載,網址為 http://git-scm.com/download/mac。
    • 在Windows上,在 Windows 上安裝 Git 也有幾種安裝方法。

      • 官方版本可以在 Git 官方網站下載。 打開 http://git-scm.com/download/win,下載會自動開始。 要注意這是一個名為 Git for Windows的項目(也叫做 msysGit),和 Git 是分別獨立的項目;更多信息請訪問 http://msysgit.github.io/。

      • 另一個簡單的方法是安裝 GitHub for Windows。 該安裝程序包含圖形化和命令行版本的 Git。 它也能支持 Powershell,提供了穩定的憑證緩存和健全的 CRLF 設置。 稍后我們會對這方面有更多了解,現在只要一句話就夠了,這些都是你所需要的。 你可以在 GitHub for Windows 網站下載,網址為 http://windows.github.com。
    • 在Linux上,我們可以通過下面的方法安裝。

      如果你想在 Linux 上用二進制安裝程序來安裝 Git,可以使用發行版包含的基礎軟件包管理工具來安裝。

      • 如果以 Fedora 上為例,你可以使用 yum:

        $ sudo yum install git
      • 如果你在基于 Debian 的發行版上,請嘗試用 apt-get:

        $ sudo apt-get install git

      要了解更多選擇,Git 官方網站上有在各種 Unix 風格的系統上安裝步驟,網址為 http://git-scm.com/download/linux。

    更多信息,請點擊 這里

    OK,我們可以測試一下Git是否安裝成功。在命令行中輸入命令 git --version 查看git 的版本信息:

  3. 在Android Studio中配置Git和GitHub

    1. 打開Android Stuido,進入 Android Studio --> Preferences --> Version Control --> Git ,(Windows為 File --> Settings --> Version Control --> Git ),在 Path to Git executable 中定位到你的Git安裝目錄,然后點擊Test,如果成功你將會看到下面的提示信息。

    2. 然后在左側設置項中選擇GitHub,然后輸入你剛剛注冊好的GitHub賬號信息,點擊test,如果成功你將會看到下面的提示信息。

  4. 托管代碼

    1. 為當前工程創建一個實用且漂亮的 README.MD 文件吧。

      在項目根目錄下新建一個 README.MD 文件, MD 表示這是一份 Markdown 文件。

      README 文件作為說明文件,作用是讓瀏覽者能夠快速地了解項目。 因此,我們在寫作README時,應該包括以下幾點:

      • 為什么會有這個項目,介紹項目開發的背景
      • 項目的用途是什么,介紹項目所解決的問題
      • 怎樣使用該項目
      • 項目的開發歷程,版本變化(可選)
      • 未來的開發計劃(可選)
      • Q&A(可選)
      • 項目所使用的許可條款文件

    (我的建議是提供一份英文版的README.MD文檔,讓我們的項目不僅僅幫助同胞,也幫助歪果仁吧。)

    1. 將當前工程導入版本控制,創建Git倉庫(可選)

    2. 分享到GitHub上

      然后我們就可以在GitHub的網站上看到我們的項目了。下面是我的 紙飛機 的項目主頁。

Day 7,Q&A

至此,項目完成,教程也接近尾聲。泡杯咖啡,我們來聊聊代碼之外的事情。

  • Q: 為什么會有這篇文章?
  • A: 一方面是受到各種大牛的影響,迫切地想要為開源貢獻自己的力量;另一方面, 紙飛機 項目的維護時間已經接近一年,這篇文章也算是一個小小的總結;然后是希望通過我的文章,能夠讓后面的童鞋們少踩一些坑。

  • Q: 一周時間并沒有完成項目,怎么辦?
  • A: 項目的代碼量還是很大的,而項目現在的代碼也是我用MVP架構重構之后的。就我自己而言,理解MVP架構我就花了一段時間,而且,MVP較MVC,代碼量本身也是增加的。沒有完成的話,就多花點時間吧。(文章的標題似乎有點標題黨的嫌疑呢)

  • Q: 版權問題?
  • A: 恩,上線未經版權所有方如知乎等的許可,我們的確是侵權了。所以,請務必知曉可能承擔的后果。(貌似是挖了個坑呢?)

  • Q: 為什么是Google Play,而不是幾60應用商店,某度應用商店呢?
  • A: 瞧不上。(我不是針對在座的某一個應用商店,我是說在座的各個應用商店,除了Google Play,都是那啥)

  • Q: 我有問題需要探討,怎么聯系?
  • A:

  • Q: 最后有什么想說的?
  • A: 如果文章對你有幫助的話,請給文章點一個贊,或者給項目一個Star,土豪請隨意打賞,集齊30塊錢我想要買本關于Git的書?。(如果有大牛有實習機會的話,請推薦一下我呀)(文筆不好,還請多包涵)

 

 

來自:http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2017/0208/7096.html

 

 本文由用戶 fangjian 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!