如何用一周時間開發一款Android APP并在Google Play上線
目標:實現 紙飛機 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版本的圖標,我再次向你投來一個鄙視的眼神。
紙飛機的最終設計效果如下:
首頁使用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。
-
首先創建最基本的BaseView和BasePresenter,他們分別是所有View和Presenter的基類。
public interface BaseView<T> { // 為View設置Presenter void setPresenter(T presenter); // 初始化界面控件 void initViews(View view); }
public interface BasePresenter { // 獲取數據并改變界面顯示,在todo-mvp的項目中的調用時機為Fragment的OnResume()方法中 void start(); }
-
然后創建一個契約類,用于同一管理View和Presenter。這里以知乎日報的部分為例(如果沒有特別說明,后面的代碼均以知乎日報的部分為例,果殼精選與豆瓣一刻的代碼類似,詳細代碼可以在GitHub的repo中找到)。
public interface ZhihuDailyContract { interface View extends BaseView<Presenter> { // 顯示加載或其他類型的錯誤 void showError(); // 顯示正在加載 void showLoading(); // 停止顯示正在加載 void stopLoading(); // 成功獲取到數據后,在界面中顯示 void showResults(ArrayList<ZhihuDailyNews.Question> list); // 顯示用于加載指定日期的date picker dialog void showPickDialog(); } interface Presenter extends BasePresenter { // 請求數據 void loadPosts(long date, boolean clearing); // 刷新數據 void refresh(); // 加載更多文章 void loadMore(long date); // 顯示詳情 void startReading(int position); // 隨便看看 void feelLucky(); } }
-
在上面已經分好的子包中,創建相應的子類View和Presenter。
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() { } }
public class ZhihuDailyPresenter implements ZhihuDailyContract.Presenter { public ZhihuDailyPresenter(Context context, ZhihuDailyContract.View view) { } @Override public void loadPosts(long date, final boolean clearing) { } @Override public void refresh() { } @Override public void loadMore(long date) { } @Override public void startReading(int position) { } @Override public void feelLucky() { } @Override public void start() { } }
然后完成果殼精選頁面,豆瓣一刻的內容,就可以進行下面的工作了。
-
創建VolleySingleton,即Volley的單例。這樣,整個應用就可以只維護一個請求隊列,加入新的網絡請求也會更加的方便。
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); } }
-
然后是Model層的實現。使用了Gson之后,對JSON的轉換更加方便了,所以,我們只需要返回類型為String即可。
public interface OnStringListener { /** * 請求成功時回調 * @param result */ void onSuccess(String result); /** * 請求失敗時回調 * @param error */ void onError(VolleyError error); }
定義了兩個方法,分別為請求成功時和請求失敗時的回調。
然后定義一個StringModel的實現類–StringModelImpl。
public class StringModelImpl { private Context context; public StringModelImpl(Context context) { this.context = context; } public void load(String url, final OnStringListener listener) { StringRequest request = new StringRequest(url, new Response.Listener<String>() { @Override public void onResponse(String s) { listener.onSuccess(s); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError volleyError) { listener.onError(volleyError); } }); VolleySingleton.getVolleySingleton(context).addToRequestQueue(request); } }
-
到這里,基本的架構就搭建完成了。現在可以喝杯咖啡,然后完成今天的最后一點工作,為后面的工作做準備。
創建 Api.java 文件,用于存儲app所用到的所有API。
public class Api { // 消息內容獲取與離線下載 // 在最新消息中獲取到的id,拼接到這個NEWS之后,可以獲得對應的JSON格式的內容 public static final String ZHIHU_NEWS = "http://news-at.zhihu.com/api/4/news/"; // 過往消息 // 若要查詢的11月18日的消息,before后面的數字應該為20161118 // 知乎日報的生日為2013 年 5 月 19 日,如果before后面的數字小于20130520,那么只能獲取到空消息 public static final String ZHIHU_HISTORY = "http://news.at.zhihu.com/api/4/news/before/"; // 獲取果殼精選的文章列表,通過組合相應的參數成為完整的url public static final String GUOKR_ARTICLES = "http://apis.guokr.com/handpick/article.json?retrieve_type=by_since&category=all&limit=25&ad=1"; // 獲取果殼文章的具體信息 V1 public static final String GUOKR_ARTICLE_LINK_V1 = "http://jingxuan.guokr.com/pick/"; // 豆瓣一刻 // 根據日期查詢消息列表 public static final String DOUBAN_MOMENT = "https://moment.douban.com/api/stream/date/"; // 獲取文章具體內容 public static final String DOUBAN_ARTICLE_DETAIL = "https://moment.douban.com/api/post/"; }
創建 NetworkState.java 文件,判斷當前的網絡狀態,是否有網絡連接,WiFi或者是移動數據。
public class NetworkState { // 檢查是否連接到網絡 public static boolean networkConnected(Context context){ if (context != null){ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = manager.getActiveNetworkInfo(); if (info != null) return info.isAvailable(); } return false; } // 檢查WiFi是否連接 public static boolean wifiConnected(Context context){ if (context != null){ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = manager.getActiveNetworkInfo(); if (info != null){ if (info.getType() == ConnectivityManager.TYPE_WIFI) return info.isAvailable(); } } return false; } // 檢查移動網絡是否連接 public static boolean mobileDataConnected(Context context){ if (context != null){ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = manager.getActiveNetworkInfo(); if (info != null){ if (info.getType() == ConnectivityManager.TYPE_MOBILE) return true; } } return false; } }
創建 DateFormatter .java 文件,方便將 long 類型的日期轉換為 String 類型。
public class DateFormatter { /** * 將long類date轉換為String類型 * @param date date * @return String date */ public String ZhihuDailyDateFormat(long date) { String sDate; Date d = new Date(date + 24*60*60*1000); SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd"); sDate = format.format(d); return sDate; } public String DoubanDateFormat(long date){ String sDate; Date d = new Date(date); SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); sDate = format.format(d); return sDate; } }
OK,day 3工作完成。
DAY 4
今天的只要任務是完成首頁。
Day 4,界面編寫
我們的首頁,使用的是Activity + Fragment搭配的方式,即一個MainActivity + MainFragment + BookmarksFragment的方式。其中,MainActivity的布局文件中包含了DrawerLayout, Toolbar以及Fragment所在的容器。
MainActivity對應布局文件如下:
<?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>
<?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。
<?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代碼了。
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的狀態的保存與恢復也是在這里進行的。
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 ,將獲取到的內容以列表的形式顯示出來。并且,不難發現,果殼精選與豆瓣一刻的布局與知乎日報的列表布局相同,可以復用。
<?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,用于顯示正在加載和手動刷新。
列表子項的布局有很多種,分別是:
- 普通僅文字
- 普通文字 + 圖片
- 頭部項,用于顯示子項類型(如知乎日報,在收藏頁面會用到)
- 底部項,加載更多等
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。
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 中的 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 的 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語句或其他內容和我的文件應該不完全相同)。
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,當我們正確請求到數據后,就可以進行存儲了。
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里面完成了。
我們先將一些必須的數據通過本地廣播的形式,發送出去。
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 里接收廣播,獲取傳送的數據,然后進行網絡請求和數據存儲。
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的消息詳情內容已經不為空,那我們就直接跳過了,可以節省用戶的流量以及電量。
到這里,數據的存儲是完成了。可是怎么讀取出來呢?哈,其實也簡單,我們判斷一下當前的網絡狀態,如果用戶設備沒有連接到網路,我們就直接去數據庫中讀取,然后解析就行了。
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并沒有啟動呀。
@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
今天的內容是顯示消息詳情內容。因為我們的消息內容實際上有三種類型,這里就不再重復。怎么區分呢?我的方法是定義了一個枚舉類型:
public enum BeanType {
TYPE_ZHIHU,TYPE_GUOKR,TYPE_DOUBAN;
}
這樣,我們就能根據不同的消息類型,獲取和加載不同的消息詳情內容了。
@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格式,要么是直接給出了詳情頁的網頁地址,做簡單處理即可,可以實現復用)。
先看布局文件代碼:
<?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。
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需要完成那些功能呢?我們可以直接在契約類中定義好。
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();
}
}
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,顯示數據
嘻嘻,首先當然是獲取數據了??,需要考慮網絡連接的情況,如果網絡通暢,則直接從網絡中獲取,否則去數據庫中獲取。
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文件。
最后的顯示就非常簡單了:
@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 文件,作為設置和關于頁面的布局文件。
<?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 。
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();
}
}
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);
}
}
是不是有種似曾相識的感覺呢??
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 方法等。
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 字段,用于標示當前一行是否被收藏。我們先看看如何添加收藏和取消收藏。
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();
}
那么如果在收藏頁面中展示出來呢?套路,仍然是之前的套路。還是熟悉的配方,還是原來的味道。
布局文件也就是簡單的一個列表,代碼和之前首頁的代碼相同,不再寫出。
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();
}
}
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);
}
}
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版本的適配,還有對多屏幕的適配,特殊用戶的適配等等。
-
多語言適配
在 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
更多內容請點擊 這里 。
-
高低Android版本適配
關于Android碎片化的討論已經非常多了,這里我就不再鞭尸了。只簡單的舉兩個例子。在Android 4.1 Jelly Bean系統的華為手機上,系統的DatePicker的樣式是這樣的。
很明顯,這和我們Material Design的設計語言很違和,使用開源庫 materialdatetimepicker 就可以在這樣的低版本的設備上實現MD版本的Date Picker Dialog。實現UI的統一對提升用戶體驗還是很有幫助的。
對于高版本,例如Android 7.x Nougat,有很多的新特性,例如新的通知欄,Shortcuts等等,雖然用上最新系統的用戶量可能不大,但是當這些用戶看到我們的應用適配了這些新特性時,應該也會感覺到眼前一亮吧,起到了錦上添花的作用。
-
支持多種屏幕
和多語言適配類似,我們也可以通過提供不同資源文件的方式實現適配。例如,以下應用資源目錄 為不同屏幕尺寸和不同可繪制對象提供不同的布局設計。使用 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
更多內容請點擊 這里 。
-
其他適配
哈,做完今天的工作,編碼的工作就完成的差不多了。好好的睡一覺,準備明天的工作。
DAY 7
Day 7,在Google Play上線
-
注冊Google Play開發者賬號
工具準備:
- KX上網,你懂的
- Chrome瀏覽器或Firefox瀏覽器
- $25,25刀的注冊費用
- 支持國際支付功能(VISA, Master等)的信用卡,便于支付25刀的注冊費
好了,我們現在開始正式的搞事情。
-
注冊Google賬號
如果你已經有了Google賬號,就直接跳過這一小步吧。
我們先去 https://accounts.google.com/SignUp 注冊賬號。按照自身的信息填寫即可。
-
登錄開發者后臺
登錄 https://play.google.com/apps/publish/signup/ 。
勾選同意并點擊繼續付款。需要注意的是,我們要先進到付款頁面,然后再綁定Google Wallet。否則的話,就不能保證付款成功了。
-
付款
點擊添加新的付款方式,一路按提示輸入即可(由于我之前已經注冊過了,這里盜用一下被人的圖,原作者請不要打我?)。
如果綁定成功,Google可能會先從信用卡中扣除$1進行授權。
-
審核
Google最多需要48小時進行審核。我們可以通過 Google Wallet 查看該訂單的支付狀態。如果顯示 已完成 ,就說明GP賬號申請成功了。
-
沒有信用卡怎么辦?
相信有很多像我一樣的學生黨,沒有信用卡或者信用卡不支持國際支付功能,該怎么解決呢?這個時候,就是萬能的某寶發揮作用的時候了。有一種信用卡叫做 虛擬信用卡 ,我們可以通過向虛擬信用卡充值,然后用這樣的卡去支付那25刀。具體的地址等咨詢店小二即可。如果你覺得這樣的方法太繁瑣,或者我有錢任性,那么直接在馬爸爸的網站上直接買一個開發者賬號吧,不過一般情況下,費用肯定是高于25美刀的,而且安全性也值得檢驗(如果你打算買賬號,那么務必在拿到賬號之后第一時間修改密碼和認證信息等等)。
通過上面的步驟,我們申請到的賬號還只能發布免費的應用。如果對應用進行收費,你可以查看控制臺中 財務報表 ,獲取更多商家賬戶的信息。
更多信息,請點擊 這里 。
-
有了賬號,我們就需要生成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 。
-
上傳應用
現在我們就可以把應用上傳到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開源
-
注冊GitHub
GitHub 是一個
同性交友社區面向開源及私有軟件項目的托管平臺,作為開源代碼庫以及版本控制系統,Github擁有超過900萬開發者用戶。隨著越來越多的應用程序轉移到了云上,Github已經成為了管理軟件開發以及發現已有代碼的首選方法。
我們先注冊賬號,地址為: https://github.com 。
賬號注冊成功后,進入
GayHubGitHub 個人信息頁,大概是這個樣子的。
第一步的工作就完成了。
-
安裝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 的版本信息:
- 在macOS上,在 Mac 上安裝 Git 有多種方式。
-
在Android Studio中配置Git和GitHub
-
打開Android Stuido,進入 Android Studio --> Preferences --> Version Control --> Git ,(Windows為 File --> Settings --> Version Control --> Git ),在 Path to Git executable 中定位到你的Git安裝目錄,然后點擊Test,如果成功你將會看到下面的提示信息。
-
然后在左側設置項中選擇GitHub,然后輸入你剛剛注冊好的GitHub賬號信息,點擊test,如果成功你將會看到下面的提示信息。
-
-
托管代碼
-
為當前工程創建一個實用且漂亮的 README.MD 文件吧。
在項目根目錄下新建一個 README.MD 文件, MD 表示這是一份 Markdown 文件。
README 文件作為說明文件,作用是讓瀏覽者能夠快速地了解項目。 因此,我們在寫作README時,應該包括以下幾點:
- 為什么會有這個項目,介紹項目開發的背景
- 項目的用途是什么,介紹項目所解決的問題
- 怎樣使用該項目
- 項目的開發歷程,版本變化(可選)
- 未來的開發計劃(可選)
- Q&A(可選)
- 項目所使用的許可條款文件
(我的建議是提供一份英文版的README.MD文檔,讓我們的項目不僅僅幫助同胞,也幫助歪果仁吧。)
-
將當前工程導入版本控制,創建Git倉庫(可選)
-
分享到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:
- marktonymengyi#gmail.com
- 知乎
- 微博
- GitHub
- 我的個人博客地址: https://marktony.github.io/
- Q: 最后有什么想說的?
- A: 如果文章對你有幫助的話,請給文章點一個贊,或者給項目一個Star,土豪請隨意打賞,集齊30塊錢我想要買本關于Git的書?。(如果有大牛有實習機會的話,請推薦一下我呀)(文筆不好,還請多包涵)
來自:http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2017/0208/7096.html