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中去的。

推荐阅读更多精彩内容