Android--从零开始开发一款文章阅读APP

时间:2023-03-10 01:09:26
Android--从零开始开发一款文章阅读APP

代码地址如下:
http://www.demodashi.com/demo/11212.html

前言

本案例已经开源!如果你想免费下载,可以访问我的Github,所有案例均在上面,只求给个star。当然愿意支付小小金额请我喝茶也行(大学穷狗-.-)

一、准备工作

  • 使用Android Studio开发
  • 微信和QQ第三方sdk,需要自行申请(这个简单)
  • 本案例使用干活*提供的api,使用MVp+Material Design作为主体架构进行开发
  • 体验完整功能,点击下载APK

二、程序实现

目录结构

目录结构如下,我按照功能分包:

Android--从零开始开发一款文章阅读APP

实现思路

整体架构--MVP+Material

  • 首先你得了解MVP架构在android中的使用,如果你还不了解,可以阅读我的这篇文章
  • 如果你不熟悉Material可以读官方文档

重点代码分析

如果讲述整个App,估计一篇文章说不清楚。那我干脆取其中一条线来分析。

下面主要分析文章列表--文章详情--文章分享

主页文章列表

这里只选择Android文章模块进行介绍:

Android--从零开始开发一款文章阅读APP

GankContract

public interface GankContract {
interface View extends BaseView<Presenter>{
//错误
void showError();
//正在加载
void showLoading();
//停止加载
void Stoploading();
//显示数据列表
void showResult(ArrayList<GankNews.Question> list);
//网络错误
void showNotNetError(); }
interface Presenter extends BasePresenter{
// 请求数据
void loadPosts(int PagerNum, boolean cleaing);
//刷新数据
void reflush();
//加载更多
void loadMore(int PagerNum);
//显示详情
void StartReading(int positon);
//随便看看
void LookAround();
}
}

GankFragment

Fragment的内容主要是文章列表,我们只分享重点:

//下拉刷新实现
recyclerView.setOnScrollListener(new RecyclerView.OnScrollListener() {
boolean isScrollState=false;
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
LinearLayoutManager manager= (LinearLayoutManager) recyclerView.getLayoutManager();
//没有滚动时候
if (newState==RecyclerView.SCROLL_STATE_IDLE){
//获的最后一个可见的item
int lastVisibilityItem=manager.findLastCompletelyVisibleItemPosition();
int totalItemCount=manager.getItemCount(); //判断是否滚动到底部并且是向下滑动
if (lastVisibilityItem==(totalItemCount-1)&&isScrollState){
presenter.loadMore(1);
}
} } //通知Presenter加载数据和设置item点击事件
@Override
public void showResult(ArrayList<GankNews.Question> list) {
if (adapter==null){
Log.i(TAG, "showResult: "+list.size());
adapter=new GankNewsAdapter(list,getContext());
adapter.setItemOnClickListener(new OnRecyclerViewOnClickListener() {
@Override
public void onItemClick(View v, int position) {
presenter.StartReading(position);
} @Override
public void onItemLongClick(View v, int position) { }
});
recyclerView.setAdapter(adapter);
}else {
adapter.notifyDataSetChanged();
}
}

GankPresenter

同样只分析重点代码:

//根据当前页数加载列表数据
@Override
public void loadPosts(int PagerNum, final boolean cleaing) {
CurrentPagerNum=PagerNum;
if (cleaing) {
view.showLoading();
}
if (Network.networkConnected(context)) {
model.load(Api.Gank_Android + PagerNum, new OnStringListener() {
@Override
public void onSuccess(String result) {
try {
// Log.i(TAG, "gankpresenter.model.load.result"+result);
GankNews news = gson.fromJson(result, GankNews.class);
//contenvalues只能存储基本类型的数据,像string,int之类的,不能存储对象这种东西,而HashTable却可以存储对象。
// ContentValues values = new ContentValues();
if (cleaing) {
list.clear();
}
for (GankNews.Question item : news.getResults()) {
/**
* 1.数据库查重:首先检测数据库中是否已经储存过该条数据
* 2:因为每次重启后都是在网络上重新下载数据 如果是数据库已经存在的数据则不会重新加载,也导致了这些数据当前id值为空
* ,所有要绑定队友的id值.
*/
if (!queryIfIdExists(item.get_id())){
DbLiteOrm.insert(item, ConflictAlgorithm.Replace);
}else {
ArrayList<GankNews.Question> ganklist=App.DbLiteOrm.query(new QueryBuilder<GankNews.Question>(GankNews.Question.class)
.where(GankNews.Question.COL_ID+"=?",new String[]{item.get_id()}));
GankNews.Question gankitem=ganklist.get(0);
item.setId(gankitem.getId());
}
list.add(item);
}
view.showResult(list);
}catch (JsonSyntaxException e){
view.showError();
}
view.Stoploading();
} @Override
public void onError(VolleyError error) {
view.Stoploading();
view.showError();
}
});
} else {
//更新列表缓存 因为详情页都是用webView呈现 所以缓存content为空
if (cleaing){
QueryBuilder query=new QueryBuilder(GankNews.Question.class);
query.appendOrderDescBy("id");
query.limit(0,10*CurrentPagerNum);
list.addAll(DbLiteOrm.<GankNews.Question>query(query));
view.showResult(list);
}else {
view.showNotNetError();
}
}
} //判断数据库是否已经存在
public boolean queryIfIdExists(String _id){
ArrayList<GankNews.Question> questionArrayList=App.DbLiteOrm.query(new QueryBuilder(GankNews.Question.class)
.where(GankNews.Question.COL_ID+"=?",new String[]{_id}));
if (questionArrayList.size()==0){
return false;
}
return true;
} //传递当前点击item的信息,进入详情阅读
@Override
public void StartReading(int positon) {
//每个item就是一组数据
GankNews.Question item=list.get(positon);
Intent intent = new Intent(context, DetailActivity.class);
intent.putExtra("type", BeanTeype.TYPE_Gank);
intent.putExtra("id",list.get(positon).getId());
int id=list.get(positon).getId();
Log.i(TAG, "StartReading: "+id);
intent.putExtra("_id", list.get(positon).get_id());
intent.putExtra("url",list.get(positon).getUrl());
intent.putExtra("title", list.get(positon).getDesc());
if (item.getImages()==null){
intent.putExtra("imgUrl", "");
}else {
intent.putExtra("imgUrl", list.get(positon).getImages().get(0));
}
/**
* Content的startActivity方法,需要开启一个新的task。如果使用 Activity的startActivity方法,
* 不会有任何限制,因为Activity继承自Context,重载了startActivity方法。
*/
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
} //随便看看 随机选取
@Override
public void LookAround() {
if (list.isEmpty()){
view.showError();
return;
}
StartReading(new Random().nextInt(list.size()));
}

GankNewsAdapter

因为文章分两种:有图和无图。所有要进行分类加载

//判断是否有图和是否是底部加载item
@Override
public int getItemViewType(int position) {
if (position==getItemCount()-1){
return TYPE_FOOTER;
}if (list.get(position).getImages()==null){
return TYPE_NO_IMG;
}
return TYPE_NORMTAL;
} //根据type加载不同ViewHolder
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
switch (viewType){
case TYPE_NORMTAL:
return new NormalViewHolder(inflater.inflate(R.layout.home_list_item_layout,parent,false),listener);
case TYPE_FOOTER:
return new FooterViewHolder(inflater.inflate(R.layout.list_footer,parent,false));
case TYPE_NO_IMG:
return new NoImageViewHolder(inflater.inflate(R.layout.home_list_item_without_image,parent,false),listener);
}
return null;
} //使用Glide加载图片。无图则不加载
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
if (!(holder instanceof FooterViewHolder)){
GankNews.Question item=list.get(position);
if (item!=null){ if (holder instanceof NormalViewHolder){
Glide.with(context)
.load(item.getImages().get(0))
.asBitmap()
.placeholder(R.mipmap.loading)
.diskCacheStrategy(DiskCacheStrategy.SOURCE)
.error(R.mipmap.loading)
.centerCrop()
.into(((NormalViewHolder) holder).imageView);
((NormalViewHolder) holder).textView.setText(item.getDesc());
}else if (holder instanceof NoImageViewHolder){
((NoImageViewHolder) holder).textViewNoImg.setText(item.getDesc());
}
}
} }

详情页

Android--从零开始开发一款文章阅读APP

DetailActivity

 @Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.frame);
if (savedInstanceState!=null){
detailFragment= (DetailFragment) getSupportFragmentManager().getFragment(savedInstanceState,"detailFragment");
}else {
detailFragment=DetailFragment.newInstance();
getSupportFragmentManager().beginTransaction().replace(R.id.container,detailFragment).commit();
}
//获取列表传过来的具体item数据
Intent intent=getIntent();
DetailPresenter presenter=new DetailPresenter(detailFragment,DetailActivity.this);
presenter.setType((BeanTeype) intent.getSerializableExtra("type"));
presenter.setId(intent.getIntExtra("id",1));
presenter.set_id(intent.getStringExtra("_id"));
presenter.setTitle(intent.getStringExtra("title"));
presenter.setUrl(intent.getStringExtra("url"));
presenter.setImgUrl(intent.getStringExtra("imgUrl"));
}

DetailContract

public class DetailContract {
interface Presenter extends BasePresenter{
/**
* 流浪器中打开
* 复制文本
* 复制连接
* 添加收藏或取消收藏
* 查询是否收藏
* 请求数据
* 分享到QQ
* 分享到微信
* 分享到朋友圈
* 分享到微信收藏
*/
void openInBrower();
void copyText();
void copyLink();
void addToOrDeleteFromBookMarks();
boolean queryIsBooksMarks();
void requestData();
void shareArticleToQQ(final MyQQListener listener);
void shareArticleToWx();
void shareArticleToWxCommunity();
void shareArticleToWxCollect();
}
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();
void showNotNetError(); void shareSuccess();
void shareError();
void shareCancel();
}
}

DetailFragment

详情页主题是使用WebView显示,重点注意好设置属性和正确销毁:

 @Override
public void initView(View view) {
......
//webview设置属性
webview.getSettings().setJavaScriptEnabled(true);
//缩放,设置为不能缩放可以防止页面上出现放大和缩小的图标
webview.getSettings().setBuiltInZoomControls(false);
//缓存
webview.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE);
//开启DOM storage API功能
webview.getSettings().setDomStorageEnabled(true);
//开启application Cache功能
webview.getSettings().setAppCacheEnabled(false);
..... } //早onDestroy中销毁WebView的对象
@Override
public void onDestroyView() {
super.onDestroyView();
webview.removeAllViews();
webview.destroy();
webview=null;
}

DetailPresenter

//复制链接地址
@Override
public void copyLink() {
ClipboardManager manager= (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData data=null;
switch (type){
case TYPE_Gank:
data=ClipData.newPlainText("text",url);
}
manager.setPrimaryClip(data);
view.showTextCopied();
} //添加到收藏或者移除收藏
@Override
public void addToOrDeleteFromBookMarks() {
switch (type){
case TYPE_Gank:
GankNews.Question gank= App.DbLiteOrm.queryById(id,GankNews.Question.class);
if (queryIsBooksMarks()){
view.showDeletedFromBookmarks();
gank.mark=false;
}else {
view.showAddedToBookmarks();
gank.mark=true;
}
App.DbLiteOrm.update(gank);
break;
case TYPE_Front:
FrontNews.Question front=App.DbLiteOrm.queryById(id,FrontNews.Question.class);
if (queryIsBooksMarks()){
view.showDeletedFromBookmarks();
front.mark=false;
}else {
view.showAddedToBookmarks();
front.mark=true;
}
App.DbLiteOrm.update(front);
break;
case TYPE_IOS:
IosNews.Question ios=App.DbLiteOrm.queryById(id,IosNews.Question.class);
if (queryIsBooksMarks()){
view.showDeletedFromBookmarks();
ios.mark=false;
}else {
view.showAddedToBookmarks();
ios.mark=true;
}
App.DbLiteOrm.update(ios);
}
} //查询是否已经收藏
@Override
public boolean queryIsBooksMarks() {
if (_id ==null || type==null){
view.showLoadingError();
return false;
}
//true为已经收藏 false未收藏
switch (type){
case TYPE_Gank:
GankNews.Question gank= App.DbLiteOrm.queryById(id,GankNews.Question.class);
OrmLog.i(TAG,gank);
boolean isMark=gank.mark;
if (isMark){
return true;
}else {
return false;
}
case TYPE_Front:
FrontNews.Question front=App.DbLiteOrm.queryById(id,FrontNews.Question.class);
if (front.mark){
return true;
}else {
return false;
}
case TYPE_IOS:
Log.i(TAG, "queryIsBooksMarks: "+id);
IosNews.Question ios=App.DbLiteOrm.queryById(id,IosNews.Question.class);
OrmLog.i(TAG,ios);
if (ios.mark){
return true;
}else {
return false;
}
}
return false;
} //分享到QQ
@Override
public void shareArticleToQQ(MyQQListener listener) {
//title == desc
if (TextUtils.isEmpty(imgUrl)){
ShareSingleton.getInstance().shareToQQ((Activity) context,url,"推荐给你一篇文章",title, R.string.app_name, QQShare.SHARE_TO_QQ_FLAG_QZONE_ITEM_HIDE,listener);
}else {
ShareSingleton.getInstance().shareToQQ((Activity) context,url,"推荐给你一篇文章",title,imgUrl,R.string.app_name, QQShare.SHARE_TO_QQ_FLAG_QZONE_ITEM_HIDE,listener);
}
} //分享到微信
@Override
public void shareArticleToWx() {
//title == desc
ShareSingleton.getInstance().shareWebToWx(url,"",title,true);
} //分享到朋友圈
@Override
public void shareArticleToWxCommunity() {
//title == desc
ShareSingleton.getInstance().shareWebToWx(url,"",title,false);
} //分享到微信收藏
@Override
public void shareArticleToWxCollect() {
//title == desc
ShareSingleton.getInstance().shareWebToWxCollect(url,"干货",title);
}

ShareSingleton

关于微信和QQ分享的具体方法还得参考官方文章,我这里提出我自己写好的分享单例类

public class ShareSingleton {

    private Tencent mTencent;
public static IWXAPI api; private static final int THUMB_SIZE = 150; //单例模式
private ShareSingleton() {
}
public static final ShareSingleton getInstance(){
return Singleton.INSTANCE;
}
private static class Singleton{
private static final ShareSingleton INSTANCE=new ShareSingleton();
} /**
* 图文分享 图片来源网络
* !! 分享操作要在主线程中完成
* @param activity
* @param targetUrl 这条分享消息被好友点击后的跳转URL。
* @param shareTitle 分享的标题, 最长30个字符。
* @param shareSummary 分享的消息摘要,最长40个字。
* @param netImgUrl 可填 分享图片的URL或者本地路径
* @param appName 手Q客户端顶部,替换“返回”按钮文字,如果为空,用返回代替
* @param shareToQQExtInt 额外选项 是否自动打开分享到QZone的对话框
* @param listener 分享回调接口
*/
public void shareToQQ(Activity activity,String targetUrl,String shareTitle,String shareSummary,
@Nullable String netImgUrl,@StringRes int appName,int shareToQQExtInt,MyQQListener listener){
if (mTencent==null){
mTencent=Tencent.createInstance(Constants.QQ_APP_ID,activity.getApplicationContext());
}
final Bundle params = new Bundle();
params.putInt(QQShare.SHARE_TO_QQ_KEY_TYPE, QQShare.SHARE_TO_QQ_TYPE_DEFAULT);
params.putString(QQShare.SHARE_TO_QQ_TARGET_URL,targetUrl);
params.putString(QQShare.SHARE_TO_QQ_TITLE, shareTitle);
params.putString(QQShare.SHARE_TO_QQ_SUMMARY, shareSummary );
params.putString(QQShare.SHARE_TO_QQ_IMAGE_URL, netImgUrl);
params.putString(QQShare.SHARE_TO_QQ_APP_NAME,activity.getString(appName));
params.putInt(QQShare.SHARE_TO_QQ_EXT_INT, shareToQQExtInt);
mTencent.shareToQQ(activity, params, listener);
} /**
* 文章分享 无图
* !! 分享操作要在主线程中完成
* @param activity
* @param targetUrl 这条分享消息被好友点击后的跳转URL。
* @param shareTitle 分享的标题, 最长30个字符。
* @param shareSummary 分享的消息摘要,最长40个字。
* @param appName 手Q客户端顶部,替换“返回”按钮文字,如果为空,用返回代替
* @param shareToQQExtInt 额外选项 是否自动打开分享到QZone的对话框
* @param listener 分享回调接口
*/
public void shareToQQ(Activity activity,String targetUrl,String shareTitle,String shareSummary
,@StringRes int appName,int shareToQQExtInt,MyQQListener listener){
if (mTencent==null){
mTencent=Tencent.createInstance(Constants.QQ_APP_ID,activity.getApplicationContext());
}
final Bundle params = new Bundle();
params.putInt(QQShare.SHARE_TO_QQ_KEY_TYPE, QQShare.SHARE_TO_QQ_TYPE_DEFAULT);
params.putString(QQShare.SHARE_TO_QQ_TARGET_URL,targetUrl);
params.putString(QQShare.SHARE_TO_QQ_TITLE, shareTitle);
params.putString(QQShare.SHARE_TO_QQ_SUMMARY, shareSummary );
params.putString(QQShare.SHARE_TO_QQ_APP_NAME,activity.getString(appName));
params.putInt(QQShare.SHARE_TO_QQ_EXT_INT, shareToQQExtInt);
mTencent.shareToQQ(activity, params, listener);
} /**
* 分享文章到微信/朋友圈
* @param webUrl
* @param webTitle
* @param webDesc
* @param isShareFriend
*/
public void shareWebToWx(@NonNull String webUrl,String webTitle,String webDesc,boolean isShareFriend){
// 注册操作也可以写死在Application中
// 通过WXAPIFactory工厂,获取IWXAPI的实例
api=WXAPIFactory.createWXAPI(App.getContext(),Constants.WX_APP_ID,true);
// 将该app注册到微信
api.registerApp(Constants.WX_APP_ID); //初始化一个WXWebpageObject对象,填写url
WXWebpageObject webpag=new WXWebpageObject();
webpag.webpageUrl=webUrl; //用WXWebpageObject对象初始化一个WXMediaMessage对象 填写标题和描述
WXMediaMessage msg=new WXMediaMessage(webpag);
msg.title=webTitle;
msg.description=webDesc; //构造一个Req
SendMessageToWX.Req req=new SendMessageToWX.Req();
req.transaction=buildTransaction("webpage");//transaction 字段用于唯一标识一个请求
req.message= msg;
req.scene=isShareFriend ? SendMessageToWX.Req.WXSceneSession : SendMessageToWX.Req.WXSceneTimeline; api.sendReq(req);
} /**
* 分享文章到微信收藏
* @param webUrl
* @param webTitle
* @param webDesc
*/
public void shareWebToWxCollect(@NonNull String webUrl, String webTitle, String webDesc){
// 注册操作也可以写死在Application中
// 通过WXAPIFactory工厂,获取IWXAPI的实例
api=WXAPIFactory.createWXAPI(App.getContext(),Constants.WX_APP_ID,true);
// 将该app注册到微信
api.registerApp(Constants.WX_APP_ID); //初始化一个WXWebpageObject对象,填写url
WXWebpageObject webpag=new WXWebpageObject();
webpag.webpageUrl=webUrl; //用WXWebpageObject对象初始化一个WXMediaMessage对象 填写标题和描述
WXMediaMessage msg=new WXMediaMessage(webpag);
msg.title=webTitle;
msg.description=webDesc; //构造一个Req
SendMessageToWX.Req req=new SendMessageToWX.Req();
req.transaction=buildTransaction("webpage");//transaction 字段用于唯一标识一个请求
req.message= msg;
req.scene=SendMessageToWX.Req.WXSceneFavorite; api.sendReq(req);
}

这篇文章就分析这么多,如果你想了解跟多,欢迎下载源码。主要部分源码都有注释

三、部分运行效果

Android--从零开始开发一款文章阅读APP

Android--从零开始开发一款文章阅读APP

Android--从零开始开发一款文章阅读APP

Android--从零开始开发一款文章阅读APP

Android--从零开始开发一款文章阅读APP

四、其他补充

如果你有问题可以提交到Github的issue上,也可以给我发邮件。我的邮件是yeshuwei.swy@gmail.com

Android--从零开始开发一款文章阅读APP

代码地址如下:
http://www.demodashi.com/demo/11212.html

注:本文著作权归作者,由demo大师代发,拒绝转载,转载需要作者授权