OSCHINA客户端完全剖析(三)分页加载和详情

分页加载

Meizitu的实现

这个功能的实现跟MobileAPI的返回大有关联,我曾经在学习Meizitu的时候看过其实现,然后练手重构了一下Meizitu

它的MobileAPI是这样的:

http://www.ourhfuu.com/meizitu.php?max_id=

http://www.ourhfuu.com/meizitu.php?since_id=

max_id是个辅助值,标明你现在所有图片中id最大的那个。分页加载需要用到since_id,当然,max_id用来对since_id进行协助计算。

先看个实例http://www.ourhfuu.com/meizitu.php?since_id=999

易知,其API返回结果为从998开始到979结束,总共返回20条结果。
不信你可以换成http://www.ourhfuu.com/meizitu.php?since_id=979 再试一下。

客户端那边,Meizitu采用数据库存储+Loader,数据流上为

  1. 从网络加载数据
  2. 加载好的数据保存至数据库中
  3. Loader触发观察者效果,根据数据库swapCursor()更新

因为MobileAPI符合之前看到的特性,所以分页加载数据再保存至数据库时无需考虑存在数据重复的问题,又因为使用了Loader,所以不用进行手动的更新UI显示。

总的来说,这样做法非常简洁明了,但问题就在于数据流还绕了一圈,没有从网络加载的数据返回后就直接更新到UI来得自然。不过实际上也没有什么问题就是了,反正已经使用数据库了,存数据库这一步操作不能省略,而更新到UI的话是需要格式化从网络返回的JSON数据的,直接读数据库读的就是格式化后的数据。

OSCHINA的实现

OSCHINA的API返回不是JSON,而是XML,但这不是关键,关键在于其接口返回的数据有什么特性。

这里MobileAPI的参数比Meizitu多代码更绕用法上也更复杂,最直观的做法可以直接抓包然后改改参数看看效果。

不过,我这边是直接看了看代码分析的,先看综合页中的使用的API接口:

/**
     * 获取新闻列表
     *
     * @param catalog
     *            类别 (1,2,3)
     * @param page
     *            第几页
     * @param handler
     */
    public static void getNewsList(int catalog, int page,
            AsyncHttpResponseHandler handler) {
        RequestParams params = new RequestParams();
        params.put("catalog", catalog);
        params.put("pageIndex", page);
        params.put("pageSize", AppContext.PAGE_SIZE);
        if (catalog == NewsList.CATALOG_WEEK) {
            params.put("show", "week");
        } else if (catalog == NewsList.CATALOG_MONTH) {
            params.put("show", "month");
        }
        ApiHttpClient.get("action/api/news_list", params, handler);
    }

直接就有一个第几页的参数page了,不过倒并不知道是不是跟Meizitu的API具有相同的特性。这也不算问题,后面直接看它怎么操作返回数据就能推断出来了。

经过一番阅读,发现网络数据返回后会先进行使用XStream的Bean解析,然后会变成一个类型为Bean的List传到BaseListFragment#executeOnLoadDataSuccess(List<T> data)方法中:

protected void executeOnLoadDataSuccess(List<T> data) {
        if (data == null) {
            data = new ArrayList<T>();
        }

        if (mResult != null && !mResult.OK()) {
            AppContext.showToast(mResult.getErrorMessage());
            // 注销登陆,密码已经修改,cookie,失效了
            AppContext.getInstance().Logout();
        }

        mErrorLayout.setErrorType(EmptyLayout.HIDE_LAYOUT);
        if (mCurrentPage == 0) {
            mAdapter.clear();
        }

        for (int i = 0; i < data.size(); i++) {
            if (compareTo(mAdapter.getData(), data.get(i))) {
                data.remove(i);
                i--;
            }
        }
        int adapterState = ListBaseAdapter.STATE_EMPTY_ITEM;
        if ((mAdapter.getCount() + data.size()) == 0) {
            adapterState = ListBaseAdapter.STATE_EMPTY_ITEM;
        } else if (data.size() == 0
                || (data.size() < getPageSize() && mCurrentPage == 0)) {
            adapterState = ListBaseAdapter.STATE_NO_MORE;
            mAdapter.notifyDataSetChanged();
        } else {
            adapterState = ListBaseAdapter.STATE_LOAD_MORE;
        }
        mAdapter.setState(adapterState);
        mAdapter.addData(data);
        // 判断等于是因为最后有一项是listview的状态
        if (mAdapter.getCount() == 1) {

            if (needShowEmptyNoData()) {
                mErrorLayout.setErrorType(EmptyLayout.NODATA);
            } else {
                mAdapter.setState(ListBaseAdapter.STATE_EMPTY_ITEM);
                mAdapter.notifyDataSetChanged();
            }
        }
    }

步骤非常简单明确

  1. mCurrentPage==0现在是不是分页的话,数据全部加载上去
        if (mCurrentPage == 0) {
            mAdapter.clear();
        }
  1. for循环去除现有的数据项,也就是说,要么MobileAPI不具有Meizitu的返回特性,要么就是在某种情况下可能会有重复数据项
for (int i = 0; i < data.size(); i++) {
            if (compareTo(mAdapter.getData(), data.get(i))) {
                data.remove(i);
                i--;
            }
        }

compareTo的实现也是十分暴力,如果id存在规律的话,倒是可以采用二分之类的改进一下:

protected boolean compareTo(List<? extends Entity> data, Entity enity) {
        int s = data.size();
        if (enity != null) {
            for (int i = 0; i < s; i++) {
                if (enity.getId() == data.get(i).getId()) {
                    return true;
                }
            }
        }
        return false;
    }
  1. 置adapter状态,然后添加新的数据项:
        int adapterState = ListBaseAdapter.STATE_EMPTY_ITEM;
        if ((mAdapter.getCount() + data.size()) == 0) {
            adapterState = ListBaseAdapter.STATE_EMPTY_ITEM;
        } else if (data.size() == 0
                || (data.size() < getPageSize() && mCurrentPage == 0)) {
            adapterState = ListBaseAdapter.STATE_NO_MORE;
            mAdapter.notifyDataSetChanged();
        } else {
            adapterState = ListBaseAdapter.STATE_LOAD_MORE;
        }
        mAdapter.setState(adapterState);
        mAdapter.addData(data);

看addData的实现,直接在数据集的末尾追加,然后通知改变即可:

public void addData(List<T> data) {
        if (mDatas != null && data != null && !data.isEmpty()) {
            mDatas.addAll(data);
        }
        notifyDataSetChanged();
    }

另外

其他的就是一些细节需要进行注意,比如滑动状态的细分处理。

对了,ListView最下面总会有个footerView进来表示“正在加载...”之类,这个footerView的存在导致ListView永不为空,且计算数值时也要考虑它的因素,还有就是各种情况下的显隐性了。


详情

所谓的详情界面,就是从资讯或者博客点击item进入后的Activity:

点击过后的item项灰显了,因为它已被加入了第一篇提到过的已读列表之中。

跳转的代码看NewsFragment#onItemClick:

@Override
    public void onItemClick(AdapterView<?> parent, View view, int position,
            long id) {
        News news = mAdapter.getItem(position);
        if (news != null) {
            UIHelper.showNewsRedirect(view.getContext(), news);

            // 放入已读列表
            saveToReadedList(view, NewsList.PREF_READED_NEWS_LIST, news.getId()
                    + "");
        }
    }

UIHelper也看过几次了,可以得出结论,该应用在设计上使用这个类作为中间层来统一管理UI切换的操作。
其中针对不同的url情况做了较多的判定封装,就不进行直接研读了。因为最常见的即为上面的截图“资讯详情”与“博客详情”,所以我们直接查找字串然后定位具体实现的Activity即可。

最后发现实现类为这个:

/**
 * 详情activity(包括:资讯、博客、软件、问答、动弹)
 *
 * @author FireAnt(http://my.oschina.net/LittleDY)
 * @created 2014年10月11日 上午11:18:41
 */
public class DetailActivity extends BaseActivity implements OnSendClickListener {

BaseActivity extends ActionBarActivity

这个类主要封装了黑白主题的设置、ActionBar操作、Toast操作(注意使用的是自定义的一个CommonToast)、ProgressDialog等。

DetailActivity extends BaseActivity

非常容易理解,放了多个标志位来区分进行不同的Fragment操作:

public static final int DISPLAY_NEWS = 0;
    public static final int DISPLAY_BLOG = 1;
    public static final int DISPLAY_SOFTWARE = 2;
    public static final int DISPLAY_POST = 3;
    public static final int DISPLAY_TWEET = 4;
    public static final int DISPLAY_EVENT = 5;
    public static final int DISPLAY_TEAM_ISSUE_DETAIL = 6;
    public static final int DISPLAY_TEAM_DISCUSS_DETAIL = 7;
    public static final int DISPLAY_TEAM_TWEET_DETAIL = 8;
    public static final int DISPLAY_TEAM_DIARY = 9;
    public static final int DISPLAY_COMMENT = 10;

找到资讯详情为:

@Override
    protected void init(Bundle savedInstanceState) {
        super.init(savedInstanceState);
        int displayType = getIntent().getIntExtra(BUNDLE_KEY_DISPLAY_TYPE,
                DISPLAY_NEWS);
        BaseFragment fragment = null;
        int actionBarTitle = 0;
        switch (displayType) {
            case DISPLAY_NEWS:
                actionBarTitle = R.string.actionbar_title_news;
                fragment = new NewsDetailFragment();
                break;

自顶向下分析吧,因为BaseFragment之前的篇章已分析过,所以从这里开始:

CommonDetailFragment<T extends Serializable> extends BaseFragment

看持有的域:

    protected int mId;

    protected EmptyLayout mEmptyLayout;

    protected int mCommentCount = 0;

    protected WebView mWebView;

    protected T mDetail;

    private AsyncTask<String, Void, T> mCacheTask;

意料之中,使用WebView来实现网页浏览,具有效果表现需要看服务器端的适配情况了。

看布局文件,发现其标题、作者、时间三项是使用原生控件来做的:

        <ScrollView
            android:id="@+id/sv_news_container"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fadingEdge="none"
            android:scrollbars="vertical">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="vertical" >

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:background="@color/white"
                    android:orientation="vertical"
                    android:padding="@dimen/space_8"
                    android:visibility="gone"
                    android:id="@+id/ll_header">

                    <TextView
                        android:id="@+id/tv_title"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:textColor="@color/main_black"
                        android:textSize="@dimen/text_size_18"
                        android:textStyle="bold" />

                    <LinearLayout
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="@dimen/space_4"
                        android:gravity="center_vertical"
                        android:orientation="horizontal" >

                        <TextView
                            android:id="@+id/tv_time"
                            android:layout_width="wrap_content"
                            android:layout_height="wrap_content"
                            android:gravity="left|center_vertical"
                            android:textColor="@color/main_gray"
                            android:textSize="@dimen/text_size_12" />

                        <TextView
                            android:id="@+id/tv_source"
                            android:layout_width="wrap_content"
                            android:layout_height="wrap_content"
                            android:layout_marginLeft="@dimen/space_10"
                            android:clickable="true"
                            android:textColor="@color/lightblue"
                            android:textSize="@dimen/text_size_12" />

                    </LinearLayout>
                </LinearLayout>

                <WebView
                    android:id="@+id/webview"
                    android:layout_width="fill_parent"
                    android:layout_height="wrap_content" />
            </LinearLayout>
        </ScrollView>

看下executeOnLoadDataSuccess(T detail)中具体的载入代码:

        mWebView.loadDataWithBaseURL("", this.getWebViewBody(detail), "text/html", "UTF-8", "");
        // 显示存储的字体大小
        mWebView.loadUrl(FontSizeUtils.getSaveFontSize());

使用loadDataWithBaseURL直接加载数据,而这里的数据detail来源有两种:一是缓存文件中的数据,二是网络请求返回的数据。getWebViewBody由子类实现。
下一行则是使用javascript代码进行文本的字号大小控制了。

    public static String getSaveFontSize() {
        return getFontSize(getSaveFontSizeIndex());
    }

    public static String getFontSize(int fontSizeIndex) {
        String fontSize = "";
        switch (fontSizeIndex) {
            case 0:
                fontSize = "javascript:showSuperBigSize()";
                break;
            case 1:
                fontSize = "javascript:showBigSize()";
                break;
            case 2:
                fontSize = "javascript:showMidSize()";
                break;
            default:
                fontSize = "javascript:showSmallSize()";
                break;
        }
        return fontSize;
    }

js函数的代码在assets/detail_page.js中,如:

function showBigSize() {
    var myBody = document.getElementById('article_body');
    myBody.style.fontSize="22px";
}

至于更清晰的调用路径,首先,UIHelper 有定义该路径字串:

public class UIHelper {

    /** 全局web样式 */
    // 链接样式文件,代码块高亮的处理
    public final static String linkCss = "<script type=\"text/javascript\" src=\"file:///android_asset/shCore.js\"></script>"
            + "<script type=\"text/javascript\" src=\"file:///android_asset/brush.js\"></script>"
            + "<script type=\"text/javascript\" src=\"file:///android_asset/client.js\"></script>"
            + "<script type=\"text/javascript\" src=\"file:///android_asset/detail_page.js\"></script>" // This line
            + "<script type=\"text/javascript\">SyntaxHighlighter.all();</script>"
            + "<script type=\"text/javascript\">function showImagePreview(var url){window.location.url= url;}</script>"
            + "<link rel=\"stylesheet\" type=\"text/css\" href=\"file:///android_asset/shThemeDefault.css\">"
            + "<link rel=\"stylesheet\" type=\"text/css\" href=\"file:///android_asset/shCore.css\">"
            + "<link rel=\"stylesheet\" type=\"text/css\" href=\"file:///android_asset/css/common.css\">";

    public final static String WEB_STYLE = linkCss;

然后在子类中进行HTML文本构造时会对其进行加载:

body.append(UIHelper.WEB_STYLE).append(UIHelper.WEB_LOAD_IMAGES);

上面这句接着看后面的分析你就会看到的。

另外比较关键的部分是缓存添加与读取,其他则是添加收藏、提示登录之类的业务相关流程。

NewsDetailFragment extends CommonDetailFragment<News>

设置独特的CacheKey、MobileAPI调用、数据解析等:

@Override
    protected String getCacheKey() {
        return "news_" + mId;
    }

@Override
    protected void sendRequestDataForNet() {
        OSChinaApi.getNewsDetail(mId, mDetailHeandler);
    }

    @Override
    protected News parseData(InputStream is) {
        return XmlUtils.toBean(NewsDetail.class, is).getNews();
    }

HTML数据文本构造:

@Override
    protected String getWebViewBody(News detail) {
        StringBuffer body = new StringBuffer();
        body.append(UIHelper.WEB_STYLE).append(UIHelper.WEB_LOAD_IMAGES);
        body.append(ThemeSwitchUtils.getWebViewBodyString());
        // 添加title
        body.append(String.format("<div class='title'>%s</div>", mDetail.getTitle()));
        // 添加作者和时间
        String time = StringUtils.friendly_time(mDetail.getPubDate());
        String author = String.format("<a class='author' href='http://my.oschina.net/u/%s'>%s</a>", mDetail.getAuthorId(), mDetail.getAuthor());
        body.append(String.format("<div class='authortime'>%s    %s</div>", author, time));
        // 添加图片点击放大支持
        body.append(UIHelper.setHtmlCotentSupportImagePreview(mDetail.getBody()));


        // 更多关于***软件的信息
        String softwareName = mDetail.getSoftwareName();
        String softwareLink = mDetail.getSoftwareLink();
        if (!StringUtils.isEmpty(softwareName)
                && !StringUtils.isEmpty(softwareLink))
            body.append(String
                    .format("<div class='oschina_software' style='margin-top:8px;font-weight:bold'>更多关于: <a href='%s'>%s</a> 的详细信息</div>",
                            softwareLink, softwareName));

        // 相关新闻
        if (mDetail != null && mDetail.getRelatives() != null
                && mDetail.getRelatives().size() > 0) {
            String strRelative = "";
            for (News.Relative relative : mDetail.getRelatives()) {
                strRelative += String.format(
                        "<li><a href='%s' style='text-decoration:none'>%s</a></li>",
                        relative.url, relative.title);
            }
            body.append("<p/><div style=\"height:1px;width:100%;background:#DADADA;margin-bottom:10px;\"/>"
                    + String.format("<br/> <b>相关资讯</b><ul class='about'>%s</ul>",
                    strRelative));
        }
        body.append("<br/>");
        // 封尾
        body.append("</div></body>");
        return  body.toString();
    }

下方工具栏的话,是使用了一个ToolbarFragment extends BaseFragment来封装填充到DetailActivity中去的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,567评论 18 399
  • 《ilua》速成开发手册3.0 官方用户交流:iApp开发交流(1) 239547050iApp开发交流(2) 1...
    叶染柒丶阅读 10,512评论 0 11
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,351评论 0 17
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,431评论 25 707
  • 作者:柴田 猫没日没夜地嗜睡,而我成宿成宿地失眠; 有没有想过,可能是猫偷走了我们的睡眠? × 昨夜,我又失眠了。...
    柴田和球阅读 580评论 1 3