需求
在国内这种奇葩生态环境下,对于Android平台的移动应用,必备的功能之一就是要自带版本更新功能——这种事情本来用GooglePlay可以做得很好的……
当然这事做起来也不麻烦,开发一个后端接口就是了,或者如我之前用过的方法:通过RSS实现。
另外就是要提供一个用户反馈的渠道,以快速发现问题并加以改进,这个功能GooglePlay也有,但是在国内你懂的。
至于国内那些应用市场,实在是太多太乱,管不过来。
而Wordpress也早已经不是单纯的BLOG程序,甚至也已经不是单纯的CMS。本来我还想开发一个综合版本更新和用户反馈功能的后端平台,但是最后还是发现用WP最好。
版本更新
基本原理还是同我以前说过的那个《通过RSS实现app的自动更新》,不过那时为了简化客户端的开发,在服务端做了一个RSS转JSON的PHP程序,现在这个版本改为在客户端直接解析RSS。
为了在应用中解析RSS,这里使用了号称功能最强大的ROME库——但是说实话,还是很多坑的。比如这个ROME官方文档都是以1.0甚至更早的版本为例,但实际上最新版本是1.5,而且这个1.5连包名字都改了,害我走了很多弯路。另外,WP的RSS2.0有一些扩展功能貌似ROME无法解析,所以最终我用了WP的ATOM来解析,效果是一样的,因为ROME都可以支持。
首先是通过异步方式获取RSS:
public class AsyncFeedFetcher extends AsyncTask<String, Integer, SyndFeed> { public interface OnFetchedListener { public void onFetched(SyndFeed feed); } private String mUrl; private OnFetchedListener mCallback; public AsyncFeedFetcher(String url, OnFetchedListener callback) { mUrl = url; mCallback = callback; } @Override protected SyndFeed doInBackground(String... params) { SyndFeed result = null; try { SyndFeedInput input = new SyndFeedInput(); result = input.build(new XmlReader(new URL(mUrl))); } catch (Throwable e) { e.printStackTrace(); } return result; } @Override protected void onPostExecute(SyndFeed feed) { if (null != mCallback && null != feed) { mCallback.onFetched(feed); } } }
一个标准的异步网络请求操作,没什么好说的。执行完成后通过onPostExecute事件把取得的feed对象返回到主线程进行回调。
然后是实现自动更新的主体对象:
public class AutoUpdate implements AsyncFeedFetcher.OnFetchedListener { public interface OnUpdatedListener { public void onUpdated(final String version, final String message, final String url); } private static final String LAST_CHECK = "auto_update_last_check"; private Context mContext; private String mCategory; private OnUpdatedListener mCallback; private AsyncFeedFetcher mFetcher; private String mVersion; private SharedPreferences mPref; public AutoUpdate(Context context, String url, String category, OnUpdatedListener callback) { mContext = context; mFetcher = new AsyncFeedFetcher(url, this); mCategory = category; mCallback = callback; getVersion(); mPref = PreferenceManager.getDefaultSharedPreferences(context); } public AutoUpdate(Context context, String url) { this(context, url, "", null); } public AutoUpdate(Context context, String url, String category) { this(context, url, category, null); } public String getVersion() { if (TextUtils.isEmpty(mVersion)) { // set real version ComponentName comp = new ComponentName(mContext, getClass()); PackageInfo pInfo = null; try { pInfo = mContext.getPackageManager().getPackageInfo(comp.getPackageName(), 0); } catch (PackageManager.NameNotFoundException e) { // do nothing } mVersion = pInfo.versionName; } return mVersion; } public void checkNow(int interval) { long now = (new Date()).getTime(); long lastCheck = mPref.getLong(LAST_CHECK, 0); if (lastCheck + interval*60*1000 < now) { mFetcher.execute(); SharedPreferences.Editor pref = mPref.edit(); pref.putLong(LAST_CHECK, now); pref.commit(); } } private SyndCategory findCategory(List<SyndCategory> categories, String catName) { for (SyndCategory c: categories) { if (c.getName().equals(catName)) { return c; } } return null; } private SyndEntry findEntryByCategory(List<SyndEntry> entries, String catName) { if (TextUtils.isEmpty(catName)) { return (entries.size() > 0) ? entries.get(0) : null; } else { for (SyndEntry e : entries) { if (null != findCategory(e.getCategories(), catName)) { return e; } } return null; } } private String html2txt(String message) { Pattern p = Pattern.compile("<(?:br/?|/p|/li)\\s*>", Pattern.CASE_INSENSITIVE); message = p.matcher(message).replaceAll("\n"); p = Pattern.compile("<[^>]*>", Pattern.CASE_INSENSITIVE); message = p.matcher(message).replaceAll(""); return message.trim(); } private void showDialog(final String title, final String message, final String url) { AlertDialog.Builder builder = new AlertDialog.Builder(mContext); builder.setTitle(title); builder.setMessage(message); builder.setPositiveButton(mContext.getString(android.R.string.ok), new AlertDialog.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); if (!TextUtils.isEmpty(url)) { Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse(url)); mContext.startActivity(intent); } } }); builder.setNegativeButton(mContext.getString(android.R.string.cancel), new AlertDialog.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); } }); builder.show(); } @Override public void onFetched(SyndFeed feed) { try { SyndEntry entry = findEntryByCategory(feed.getEntries(), mCategory); if (null == entry) { return; } Pattern p = Pattern.compile(".*\\s+v\\s+([0-9\\.]*)", Pattern.CASE_INSENSITIVE); Matcher m = p.matcher(entry.getTitle()); if (!m.find()) { return; } String version = m.group(1); SyndContent content = (null != entry.getContents()) ? (SyndContent) (entry.getContents().get(0)) : null; String message = (null != content) ? content.getValue() : "No content."; p = Pattern.compile("<a\\s+[^>]*href\\s*=\\s*[‘\"]([^‘\"]*)[‘\"][^>]*>", Pattern.CASE_INSENSITIVE); m = p.matcher(message); if (!m.find()) { return; } String url = m.group(1); // remove download link p = Pattern.compile("<p>[^<]*<a\\s+[^>][^<]*</a>[^<]*</p>", Pattern.CASE_INSENSITIVE); message = p.matcher(message).replaceAll(""); message = html2txt(message); if (!version.equals(mVersion) && !TextUtils.isEmpty(url)) { if (null == mCallback) { showDialog(entry.getTitle(), message, url); } else { mCallback.onUpdated(version, message, url); } } } catch (Throwable e) { // do nothing } } }
这个东西有几个地方需要解说一下:
1、构造函数的几个参数分别为:context(用于操作preferences),url(RSS链接,推荐使用ATOM),category(WP中的分类名,可以指定WP中某个特定分类的内容为软件更新,如为空则不判断分类),callback(发现更新时的回调事件,关于这个下文另有说明)
2、checkNow为检查更新的函数,参数为检查间隔时间,以分钟为单位。如果当前时间与上次检查时间的间隔小于参数指定的时间则不作任何操作,否则启动异步网络请求去获取RSS。
3、onFetched为异步网络请求的回调事件,在这里对取得的feed进行解析。首先是取得(指定分类下)最新的一条Entry记录,然后解析其标题中包含的版本号和内容中包含的下载链接,并将内容转为纯文本格式。如果发现版本不同,则执行回调或弹出对话框(未指定回调的话)。
4、因为是针对特定格式进行解析的,所以在WP的这个指定分类下发表更新文章需要按一定的格式来:首先是标题中一定要有V开头的完整版本号,版本号必须与APP的版本号完全一致(否则会导致反复更新),建议格式为:“APP名 V 版本号”,其中V大小写均可,左右有无空格均可。其次是内容建议不要太长,以免对话框显示不下,也不要使用过于复杂的格式,不支持图片,一定要有且只有一个链接,并且这个链接指向与标题版本一致的APP文件。
5、默认对话框显示为标题是WP文章标题,内容是转成文本的WP文章内容(不包含下载链接),点击确认自动调用系统默认的下载工具(一般是浏览器)开始下载链接。
使用方法:
// MainActivity.onCreate mAutoUpdate = new AutoUpdate(this, "http://yoursite.com/blog/?feed=atom", "软件更新", null); mAutoUpdate.checkNow(24*60); // MainActivity.checkUpdate() {} mAutoUpdate.checkNow(1); // AboutFragment.onCreateView mCheckNow = (Button) view.findViewById(R.id.btnCheckNow); mCheckNow.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { MainActivity.checkUpdate(); } });
说明:
1、yoursite.com换成你自己的网址,"软件更新"换成你自己的分类名,24*60表示应用在启动时进行的版本更新检测间隔为24小时。
2、About页面中点击“立即检查”按钮的间隔时间被设定为一分钟,防止用户不必要地短时多次点击。
3、以上代码存在一个已知的问题就是:当更新检测到有新版本,触发回调事件时,如果当前Activity不是MainActivity,则默认对话框将不显示,所以如果存在这种情况,需要使用构造函数的最后一个参数,设置一个回调函数,自己处理更新事件。一个参考的处理方式是:如果当前MainAcitivity不活动,把更新内容先保存下来,让MainAcitivity在onResume里显示。
如此,自动更新的功能就实现了,以后在别的应用中有需要同样的功能,只要搭一个Wordpress就搞定。
下篇将介绍如何把用户反馈功能也整合到Wordpress里。