用Wordpress构建App更新和反馈平台(上)

需求

在国内这种奇葩生态环境下,对于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里。

时间: 2024-10-17 22:48:38

用Wordpress构建App更新和反馈平台(上)的相关文章

亚马逊AWS在线系列讲座——如何在AWS云平台上构建千万级用户应用

用户选择云计算平台来构建应用的一个重要原因是云平台的高弹性和高扩展性.面向互联网的应用往往需要支撑大量用户的使用,但是构建一个高扩展性的.高可用的应用具有一一定的挑战,不过基于AWS云平台来构建应用可以相对简化这个事情.这个在线讲座将讨论如何如何充分利用云平台的特性和AWS的相关服务来构建一个可以支撑千万级用户的应用.通过讨论不同用户数量级别的应用需求和架构特点,然后结合不同的AWS的服务来满足用户访问,并最终逐渐把架构优化成为可以支持千万级用户的设计.这个演讲的目的是帮助对AWS服务有一定基础

爱加密CEO接受创业邦独家专访:打造App运营的安全平台

摘要:近日,爱加密CEO高磊接受了由美国国际数据集团(IDG)和清科集团共同投资设立的创业邦的独家专访.目前,移动应用市场上破解版App.盗版App盛行,严重损害了开发者和用户的利益,爱加密移动安全平台为App安全提供一站式全方位保护,对盗版现象零容忍. APP开发者们(特别是Android开发者)大部分都有过这样的经历,辛辛苦苦做好的APP上架应用商店后,没过多久就遭遇"打包党"盗取源码.植入恶意病毒.添加广告SDK,然后眼睁睁地看着自己的应用被二次打包盗版"李鬼"

解决Windows平台通过cURL上传APP到蒲公英pgyer平台时无法使用中文升级描述的问题

解决Windows平台通过cURL上传APP到蒲公英pgyer平台时无法使用中文升级描述的问题 官方上传命令 curl -F [email protected]"315.apk" -F uKey=XXX -F _api_key=OOO -F updateDescription=中文 http://www.pgyer.com/piv1/app/upload 问题描述 同样的命令: 在Mac平台上传IPA文件时,能够正常显示中文更新描述 但是在Windows平台上传APK文件时,“更新提示

手游公司运维之利用Rundeck自动化运维工具和Shell脚本构建测试环境代码发布平台和生产环境代码发布平台

在做手游运维工作之前,我接触的代码发布都是常规的软件发布,有固定的发布周期.之前工作的那个外企有严格的发布周期,一年中的所有发布计划都是由Release Manager来控制,每次发布之前都需要做一些准备工作,如填写发布表单,上传发布需要的资源文件,联系发布过程中的相关人员,如开发和测试.最后在公司内部开发的发布平台上按照指定的时间点击鼠标对一个集群内的几台主机或全部主机进行代码发布.这个发布平台还是基于rsync服务实现的.虽然每个星期都有各种服务的发布,但是整个发布流程是可以控制的,并且发布

快速构建App界面的框架(●&#39;?&#39;●) -----SalutJs

前言 卤煮在公司之初接触到的是一个微信APP应用.前端技术采用的是Backbone+zepto等小型JS类库.在项目开发之初,这类中小型的项目采用这两种库可以满足基本的需求.然而,随着迭代的更新和业务的增加,成堆的代码被覆盖到项目中去了,使得这样一种技术架构方式变得异常的臃肿,很多界面变得异常的难以维护,因此卤煮打算重构公司前端架构. 卤煮的想法是:采用异步模块的加载方式,将不同微信菜单进入的界面分成若干的模块文件,这样的好处是按照需求加载界面,而且每个界面都单独成模块,便于维护和独立开发.于是

如何保护开发者利益,打造APP运营的安全平台

APP开发者们(特别是Android开发者)大部分都有过这样的经历,辛辛苦苦做好的APP上架应用商店后,没过多久就遭遇"打包党"盗取源码.植入恶意病毒.添加广告SDK,然后眼睁睁地看着自己的应用被二次打包盗版"李鬼"进入渠道跟自己争抢用户和市场. 如 2014年火爆一时的小游戏Flappy Bird,尽管正式版早已下架,但各种Android山寨版却层出不穷.安全公司McAfee对其中对300款Flappy Bird山寨版游戏进行了测试,结果发现接近80%.约238款

从国内APP更新“精雕细琢” 看国内外产品理念之差

对于当下的大众来说,智能手机已经成为新的"器官".之所以与智能手机每时每刻"捆绑"在一起,就在于在其中有太多精彩的APP,带领人们进入一个又一个精彩世界中.由此,APP成为兵家必争之地.如何能够让自家APP脱颖而出,成为众多互联网企业的必修课.除了在UI.功能.界面等方面上下功夫外,"更新"也成为最常用,也最有效果的杀手锏. 目前,国内APP的更新速度让人瞠目结舌,且更新的功能花样繁多.相比之下,国外APP更新的频率要低得多,而且更多的是以打补丁

[转]快速构建App界面的框架(●&#39;?&#39;●) -----SalutJs

前言 卤煮在公司之初接触到的是一个微信APP应用.前端技术采用的是Backbone+zepto等小型JS类库.在项目开发之初,这类中小型的项目采用这两种库可以满足基本的需求.然而,随着迭代的更新和业务的增加,成堆的代码被覆盖到项目中去了,使得这样一种技术架构方式变得异常的臃肿,很多界面变得异常的难以维护,因此卤煮打算重构公司前端架构. 卤煮的想法是:采用异步模块的加载方式,将不同微信菜单进入的界面分成若干的模块文件,这样的好处是按照需求加载界面,而且每个界面都单独成模块,便于维护和独立开发.于是

我发起了一个 .Net Core 平台上的 开源项目 ShadowDomain 用于 热更新

大家好,  我发起了一个 .Net Core 平台上的 开源项目 ShadowDomain  用于 热更新 . 简单的说, 原理就是 类似 Asp.net 那样 让 当前 WebApp 运行在一个 App Domain 中, 当 WebApp 的 Bin 目录 或者 Web.config 被更新时, 就会 创建一个 新的 App Domain, 我们把 这个 新的 App Domain 称之为  "New Domain", 把 原来的 正在运行的 App Domain 称之为  &qu