手把手系列————手机卫士(1) | 野望的小屋

749 阅读14分钟

手把手教你实现一款手机卫士。在今天的项目里面,你会实现一下几个小目标。

  1. 热门开源库的使用,xUtils3(下载文件),okHttp3(发送网络请求),Gson(处理Gson数据),permissionsdispatcher(动态权限的适配)的使用。
  2. handler的使用。
  3. 打包的流程
  4. 实现了一个本地端向服务器检查应用版本是否有更新并且下载,自动跳转安装的功能。兼容到8.0版本
  5. 以及综合考虑了用户的各种奇葩操作的相应的处理。

Splash页面功能

顾名思义,SplashActivity页面的含义就是启动时候的界面,一般来说, 用于广告位,或者检测应用是否在服务器上有更新,如果有的话就直接下载下来,并且安装。这样用户就可以获得最新版本的应用。接下来我们就要实现这个具体的功能。

效果展示

流程图

下面的流程图就很好的展示我们即将要做的事情。

添加用到的第三方库

compile 'com.squareup.okhttp3:okhttp:3.4.1'
compile 'com.google.code.gson:gson:2.7'
compile 'org.xutils:xutils:3.5.0'
  
compile('com.github.hotchemi:permissionsdispatcher:2.4.0')
annotationProcessor 'com.github.hotchemi:permissionsdispatcher-processor:2.4.0'

在这里需要值得一提的是permissionsdispatcher三方库的也就是最后两行代码的引入,并不是直接粘贴,而是通过插架加载的,ctrl+ shift+s 打开控制面板,输入Plugins,然后点击中间的按钮,输入permissionsdispatcher,就会看到这个插件了,点击安装即可(可能需要重启studio,我忘了)

//添加permissionsdispatcher依赖,重新构建一下项目就行了。
Code——>Generat....->addPermissionDispatcherdependence

这样的话,我们就添加完毕了。

activity_splash.xml

<TextView
    android:id="@+id/version_name"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerInParent="true"
    android:shadowColor="#ff0"
    android:shadowDx="1"     
    android:shadowDy="1"
    android:shadowRadius="10"
    android:text="版本名称"
    android:textColor="#fff"
    android:textSize="20sp"/>
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_above="@id/version_name"
    android:id="@+id/app_name"
    android:layout_centerHorizontal="true"
    android:text="手机卫士"
    android:textColor="#fff"
    android:textSize="30sp"/>
<ProgressBar
    style="?android:attr/progressDrawable"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_below="@id/version_name"
    android:layout_centerHorizontal="true"
    />

布局使用的RelativeLayout,为什么代码要贴出来呢,是因为有一个非常重要的点,叫做版本名称的这个textview,必须放在最前面,因为在编译的时候,一旦@id/version_name这个值没有被先编译的话,就会报错。找不到该值。

android:shadowDx="1"    
android:shadowDy="1"
android:shadowRadius="10"

这三个属性决定了版本名称的阴影效果。

Activity去头操作&保留高版本主题

第一种方式

1
android.support.v7.app.ActionBar actionBar = getSupportActionBar();
if(actionBar!=null){
    actionBar.hide();
}

得到getSupportActionBar的实例,然后进行判断,如果存在就隐藏。

第二种方式

<!--
    在manifest清单中修改theme
    去掉头的第一种方法
    android:theme="@style/Theme.AppCompat.NoActionBar" 追踪进去看实现
    <item name="windowNoTitle">true</item>
    然后添加进我们自己的appTheme中
    android:theme="@style/AppTheme"
    在AppTheme里面去掉ActionBar又保留样式
    <item name="windowNoTitle">true</item>
    -->
android:theme="@style/AppTheme"

二者的区别

区别:

  • 在oncreate里面隐藏的话,下一个Activity里,同样存在
  • 在manifest中隐藏的话,所有的anctivity中都不可见,因为是在根节点隐藏

getVersionName()

2
    /**
     * 获取版本名称
     *
     * @return 应用版本名称 返回null 代表有异常
     */
    private String getVersionName() {
        //1.得到包管理者对象PackageManager
        PackageManager manager = getPackageManager();
        try {
            //2.传入app的包名和flag(值为0),得到当前包的基本信息
            PackageInfo packageInfo = manager.getPackageInfo(this.getPackageName(), 0);
            //返回包的versionName,即是Module:app里面的versionName
            return packageInfo.versionName;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

注意返回值和传入的参数的含义

getVersionCode()

 3   
   /**
     * 获取版本号
     *
     * @return 应用版本名称 返回0 代表有异常
     */
private int getVersionCode() {
    //1.包管理者对象PackageManager
    PackageManager manager = getPackageManager();
    try {
        PackageInfo packageInfo = manager.getPackageInfo(this.getPackageName(), 0);
        return packageInfo.versionCode;
    } catch (Exception e) {
        e.printStackTrace();
    }
    return 0;
}

注意返回值和传入的参数的含义。

initData()

4
private void initData() {
        //1.应用名称
        String versionName = getVersionName();
        //2.给textView赋值
        tv_versionName.setText("版本名称:" + versionName);
        //3.检测本地版本号和服务器版本号是否相同,不相同说明有新版本,后台更新
        mVersionCode = getVersionCode();
//        4.获得服务端的版本号
//        http://ogtmd8elu.bkt.clouddn.com/update_mobilesafe.json
//        包含更新版本的名称
//        新版本的描述信息
//        获得版本号
//        下载地址
//         "versionName":"2.0",
//        "versionCode":"2"
//        "versionDes":"2.0版本发布了,狂拽酷炫吊炸天"
//        "downloadUrl":"http://yanhui.site"(替换成下载地址)
        checkVersion();
    }

这里的话,我们就已经拿到了,本地包下的版本号和版本名字。接下来要分析的 checkVersion()就是splash代码的重点。

checkVersion();

5 
//进行耗时操作必须开启一个新的子线程,不然会造成ANR异常
final long startTime = System.currentTimeMillis(); //开始的时间戳
final Message message = Message.obtain(); //初始化message对象
String address = "http://ogtmd8elu.bkt.clouddn.com/MobilesafeUpdate.json";//版本更新的json数据
HttpUtils.sendOkHttpRequest(address, new Callback() {
    @Override
    public void onFailure(Call call, IOException e) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                //切换回主线程弹出toast
                ToastUtils.showShort(SplashActivity.this, "更新数据失败,请检查网络");
            }
        });
        message.what = ENTER_HOME;//赋值给message.what
        try {
            Thread.sleep(2000);//睡两秒再发送消息体验好很多
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
        mHandler.sendMessage(message);//发送消息
    }

看上述代码,我使用了HttpUtils.sendOkHttpRequest()方法,发起网络的请求,以上是onFailure方法要走的逻辑。

  1. 首先切换到主线程,然后弹出一个土司告诉用户,是否是网络了出了问题。
  2. 把常量ENTER_HOME赋值给message.what
  3. 延时2s后,发送message。

这么可虑,既然走到了onFailure方法,说明请求失败了,那么我们就应该进入到程序的HomeActivity因此我们使用handler发送消息。

ToastUtils

7
/**
 * Created by Archer on 2017/9/16.
 * <p>
 * 功能描述:
 * 一个弹出土司的工具类
 */
public class ToastUtils {
    public static void showShort(Context context,String text){
        Toast.makeText(context,text,Toast.LENGTH_SHORT).show();
    }
    public static void showLong(Context context,String text){
        Toast.makeText(context,text,Toast.LENGTH_LONG).show();
    }
}

这就是一个很简单的封装,就不赘述了。

HttpUtils

6
/**
 * Created by Archer on 2017/9/16.
 * <p>
 * 功能描述:封装一个发起网络请求的一个工具类,
 */
public class HttpUtils {
    public static  void sendOkHttpRequest(String address,okhttp3.Callback callback){
        OkHttpClient client= new OkHttpClient();
        Request request= new Request.Builder().url(address).build();
        client.newCall(request).enqueue(callback);
    }

这就是现在最流行的网络请求开源库okhttp3的基本用法,第一个参数传入一个url,第二个传入一个callback回调函数,

  1. 得到OkHttpClient的实例
  2. 根据address得到一个请求Request
  3. 通过 OkHttpClient的实例,调用newCall方法,加入队列中。

现在再回来头上面的代码就是很清晰的吧,这个callBack里面实现了onFailure和onResponse方法,相信onFailure方法你已经明白是怎么回事了,接下来当然是要分析onResponse方法了。

服务器的Json数据

这是我放在服务器上的json数据,通过下面的这个网址就能访问到。

ogtmd8elu.bkt.clouddn.com/MobilesafeU…

我们模拟了app与服务器的交互。请求成功的话,就返回如下json数据。

{
 "versionName":"2.0",
 "versionCode":"2",
 "versionDes":"2.0版本发布了,狂拽酷炫吊炸天",
 "downloadUrl":"http://ogtmd8elu.bkt.clouddn.com/mobilesafe2.0.apk"
}

可以打开浏览器,输入adress地址,得到以上数据,很清楚了吧,意思是我们的服务器告诉我们,出现了新的版本,是2.0的版本,并且给我们了一个下载的最新版本的地址。

onResponse()

8
@Override
public void onResponse(Call call, Response response) throws IOException {
    String jsonResponseText = response.body().string();//将返回的response对象转化成String
    Log.d(TAG, "onResponse: " + jsonResponseText);
    int versionCloudCode = parseJsonObject(jsonResponseText);//解析json返回服务器上最新版本值
    if (mVersionCode < versionCloudCode) {//本地的小于服务器的
        message.what = UPDATE_VERSION;  //给message一个消息,更新版本
    } else {
        message.what = ENTER_HOME;  //进入主页面
    }
    long endtime = System.currentTimeMillis();
    if (endtime - startTime < 4000) { //保证执行4s,体验好
        try {
            Thread.sleep(4000 - (endtime - startTime));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    mHandler.sendMessage(message);//发送消息
}

上述代码非常容易理解,请求数据呈贡以后,callback给我回调的onResponse方法,reponse对象就是回调成功的返回对象类型,因此我们需要拿到它的body的string。这里注意,是string不是toString。这样的话,我们拿到了String类型的json数据jsonResponseText,因此,我们下一步调用的parseJsonObject(jsonResponseText)方法,就是把这个jsonResponseText进行解析得到服务器的版本号,然后与我们本地的版本号进行比较,如果本地版本号小于服务器的,那么给 message.what赋值一个UPDATE_VERSION的常量,由handler进行更新。否则的话(不需要更新,本地的已经是新版本了),ENTER_HOME赋值给message.what,进入程序的主界面。最终都是通过Handler发送message,通过handleMessage()进行操作。

parseJsonObject()

下面我们就要对服务器回调回来的json进行解析。

Update类

9
package site.yanhui.mobilesafe.gson;
/**
 * Created by Archer on 2017/9/16.
 * <p>
 * 功能描述:
 * GsonBean,用来传入更新的基本信息。Json实体类
 */
public class Update {
    /**
     * versionName : 2.0
     * versionCode : 2
     * versionDes : 2.0版本发布了,狂拽酷炫吊炸天
     * downloadUrl : http://yanhui.site
     */
    private String versionName;
    private String versionCode;
    private String versionDes;
    private String downloadUrl;
    public String getVersionName() {
        return versionName;
    }
    public void setVersionName(String versionName) {
        this.versionName = versionName;
    }
    public String getVersionCode() {
        return versionCode;
    }
    public void setVersionCode(String versionCode) {
        this.versionCode = versionCode;
    }
    public String getVersionDes() {
        return versionDes;
    }
    public void setVersionDes(String versionDes) {
        this.versionDes = versionDes;
    }
    public String getDownloadUrl() {
        return downloadUrl;
    }
    public void setDownloadUrl(String downloadUrl) {
        this.downloadUrl = downloadUrl;
    }
}

google 开源的Gson库,最牛的一点就是能过能够在返回的jsonString和jsonObject,jsonArray中建立起上述这种映射关系,并且非常容易使用。实体类准备好了。接下来开始解析

10
/**
 * 解析拿到的jsonObject对象
 *
 * @param jsonData 传入json返回的String
 */
private int parseJsonObject(String jsonData) {
    Gson gson = new Gson();
    Update update = gson.fromJson(jsonData, Update.class);
    Log.d(TAG, "parseJsonObject: " + update.getVersionDes());
    Log.d(TAG, "parseJsonObject: " + update.getVersionName());
    Log.d(TAG, "parseJsonObject: " + update.getVersionCode());
    Log.d(TAG, "parseJsonObject: " + update.getDownloadUrl());
    des = update.getVersionDes();
    updateDownloadUrl = update.getDownloadUrl();
    return Integer.parseInt(update.getVersionCode());
}

首先得到一个Gson对象,然后调用fromJson方法,传入到处理的json String和我们刚刚新建的实体类的class,只需要一行代码,你就可以通过调用update的get系列的方法,得到数据,这种感觉很爽吧。你也许可能会觉得,这有什么稀奇的,服务器返回的不过是一个简单jsonObject而已。使用jsonObject的解析方式也很简单,你说的没错,但是如果我们返回的是一个非常复杂的json数据的话,使用Gson就会变得非常方便和不容易出错了。这是下一个项目要介绍的内容了。先搞定这个吧,万里长征才开始了第一步。

可以打个log看一看,数据拿到没有,同时我们被得到的这个描述复制给了des,新版本apk下载地址赋值给了updateDownloadUrl ,最终把得到的服务器的版本号return了过去。样子我们就拿到了,可以进行比较了。

为什么需要handler

先简单解释一下,这个handler到底是什么情况。考虑以下的情况,现在我们的app需要更新UI界面,但是在更新UI界面时所需要的数据,却是要经过耗时操作,这该怎么处理呢。

主线程直接处理:

最直接的处理就在主线程中写耗时操作,但是很快就会发现问题,就是一旦耗时操作的时间超过(4s,还是5s)就会出现一个ARN(Application Not Responding)异常,应用没有响应,就会弹出一个对话框要求用户选择,可以选择等待或者是结束应用。这样子的话,用户的体验就太糟糕了。

子线程中处理:

显然第一种方式的处理非常糟糕,那么我们想,是否可以开启这个子线程专门处理UI更细呢?但是很快你就会发现,在子线程中是无法更新UI的(报异常),换句话中,就是当我们在更新UI的时候,必须在主线程里面,但是又不能有耗时操作(会造成ANR异常),那么最终的处理方式就是一种折中的机制。开启一个子线程,完成相关耗时操作,得到数据(数据就算准备好了),然后通知主线程去更新UI,这样就可以实现了嘛,因为数据都准备好了,就能马上更新了。

你会问,如何通知。这就是handler的作用了,Android我们提供了完整的一套上述情况的解决方式,handler就是其中的重要方式之一,实际上其他的几种方式都是对handler机制的一种封装。所以学好handler是非常重要的,在这里我就不讲handler的原理了,我会单开一篇专门研究handler。这里先讲handler在此项目中的具体使用。

handleMessage(Message msg)

相信在之前的方法中,你会先看到一个Message.obtain的Message初始化,然后无论是onFailure还是onResponse的方法中,我们把常量值如果在onResponse方法中,就把UPDATE_VERSION赋值给message.what,如果是onFailure方法中,就把ENTER_HOME赋值给message.what,这两个都市一个int的常量值,最后都使用了

mHandler.sendMessage(message);//发送消息

发送出去,接下来再又handler处理

11
private static final String TAG = "SplashActivity";
private static final int UPDATE_VERSION = 100;
private static final int ENTER_HOME = 101;
private TextView tv_versionName;
private int mVersionCode;
private Handler mHandler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        switch (msg.what) {
            case UPDATE_VERSION:
                showUpdateDialog();//启动更新的对话框
                break;
            case ENTER_HOME:
                enterHome(); //进入主界面
        }
    }
};
private String des; //服务器得到的新版本描述
private String updateDownloadUrl; //新版本apk的下载地址

所以之前我们使用的mHandler的sendMessage方法,最终就是在这里得到了处理。因为我们是用switch语句判断赋值给massage.what携带的值,然后进行相应的操作。这就非常清楚了。

handler使用小结

final Message message = Message.obtain(); //初始化message对象
message.what  赋值
handler.sendMessage(message)

第一步调用Message.obtain(),这个方法在内部执行的就是不断的从线程池里面查询是否有message.what被赋值,如果有就捕捉到,然后最后使用handler发送出去。

enterHome()

我们先看enterHome方法.

12
/**
 * 进入Home界面
 */
private void enterHome() {
    Intent intent = new Intent(SplashActivity.this, HomeActivity.class);
    startActivity(intent);
    finish();//结束当前app
}

非常简单,从启动界面,跳转到主界面,并结束当前的activity。

13
     /**
     * 弹出对话框更新界面
     */
    private void showUpdateDialog() {
        //弹出对话框,是依赖于activity存在的
        final AlertDialog.Builder alertDialog = new AlertDialog.Builder(this);
        alertDialog.setIcon(R.drawable.ic_launcher); //设置左上角图标
        alertDialog.setTitle("版本更新");
        alertDialog.setMessage(des); //设置从服务器拿到的新版本描述
        alertDialog.setPositiveButton("立即更新", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                //申请权限
                SplashActivityPermissionsDispatcher.downNewApkWithCheck(SplashActivity.this);
            }
        });
        alertDialog.setNegativeButton("取消更新", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                enterHome();
            }
        });
//        alertDialog.setCancelable(false);//不能被取消
        //弹出软件升级界面 点击back按钮同样能进入主界面
        alertDialog.setOnCancelListener(new DialogInterface.OnCancelListener() {
            @Override
            public void onCancel(DialogInterface dialog) {
                enterHome();    //进入主界面
                dialog.dismiss();//使得dialog消失
            }
        });
        alertDialog.show();//展现出dialog
    }

首先我们先使用了AlertDialog.Builder构造,构造出了一个dialog实例,然后对其进行赋值,设置图标,标题,从服务器上拿到的版本描述(这里走的是成功拿到服务器数据的回调)。点击取消的时候,我们就认为是用户取消了更新,那么当然是要进入主界面了。在这里值得一提的是setOnCancelListener方法,这个方法的含义是,比如说弹出更新对话框了,用户既没有点击立即更新,也没有点击取消更新,而是直接点击了返回键,如果没有这个方法的话,就会卡在splash界面,体验非常差,所以要设置一下这个方法,让程序能正常运行起来,进入主界面,并让dialog消失。

动态权限适配(PermissionDispatcher)

Android提升了安全性以后,比如一些敏感权限,在这里用到的写入sd卡的权限,就必须得由用户授予权限,才可以调用。因此我们用到一个三方库PermissionDispatcher,之前的依赖相信你已经完成了。

//主菜单
Code->generate..->Generate Runtime Permission

填写需要到权限的方法,需要的理由,权限拒绝以后的方法,点击不再询问的方法。

初始化xUtils3

添加完依赖以后,还需要对其进行初始化

下载新的apk文件(xUtils3)

@NeedsPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    void downNewApk() {
        ToastUtils.showShort(SplashActivity.this, "正在下载.....");
        //1.判断sd卡是否挂载
        if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
            //获得sd路径
            final String path = Environment.getExternalStorageDirectory().getAbsolutePath()
                    + File.separator + "mobilesafe1.apk";//指定存储路径(sd存储绝对路径)和存储的名称
            String url = updateDownloadUrl; //将服务器上获得下载新apk的url赋值给url进行发起请求
            RequestParams params = new RequestParams(url);//封装成param
            params.setSaveFilePath(path);//设置存储下载文件的位置
//            params.setAutoRename(true);//自动把名字改成服务器上的app名称,会覆盖,但是一旦重复下载就会多出来。
            x.http().post(params, new org.xutils.common.Callback.CommonCallback<File>() {
                @Override
                public void onSuccess(File result) {
                    Log.i(TAG, "onSuccess: ");
                    //获得的result就是下载获得的文件
                    //提升权限
                    String path1 = result.getAbsolutePath();
                    Log.d(TAG, "onSuccess: "+path1);
                    setPermission(path1);
                    installApk(SplashActivity.this, result);
                }

我们已经获得权限了,接下来就是要下载新的apk文件,注释的参数已经写的非常清楚了