Android辅助功能开发——一键优化

个人博客地址:https://blog.yookingh.cn
该文章地址:https://blog.yookingh.cn/dev/200424-accessibilityService.html

前言

公司项目需求:为App提供一键优化功能(一键获取相关所有权限),确保App在优化过后能够常驻手机,为用户提供服务。——商户类App,用户非主动关闭时常驻手机合情合理

阅读源码

  1. getWindows()和getRootInActiveWindow()

    List<AccessibilityNodeInfo> getWindows() 即:获取当前页面所有的节点——包括顶部状态栏、底部系统按钮等各自的root节点。

    AccessibilityNodeInfo getRootInActiveWindow()即:获取当前活动页面(Activity)中的root节点。

    getRootInActiveWindow()方法在页面未加载完成时可能为null,而getWindows()一直有值。

  2. AccessibilityNodeInfo

    AccessibllityNodeInfo类是含有链表结构的类(应该是这样称呼吧)。

    譬如:

    class AccessibilityNodeInfo implements Parcelable{
        ...
        public AccessibilityNodeInfo getParent() {
            throw new RuntimeException("Stub!");
        }
        
        public AccessibilityNodeInfo getChild(int index) {
            throw new RuntimeException("Stub!");
        }
        ...
    }
    

    AccessibllityNodeInfo页包含了对View的操作(仅包含有用到的部分):

    class AccessibilityNodeInfo implements Parcelable{
        ...
        //点击事件
        public static final int ACTION_CLICK = 16;
        //朝后滚动 performAction返回false即滑动到顶部
        public static final int ACTION_SCROLL_BACKWARD = 8192;
        //朝前滚动 performAction返回false即滑动到底部
        public static final int ACTION_SCROLL_FORWARD = 4096;
        ...
     public boolean performAction(int action) {
            throw new RuntimeException("Stub!");
        }
    
        public boolean performAction(int action, Bundle arguments) {
            throw new RuntimeException("Stub!");
        }
        ...
    }
    
  3. 全局事件

    public abstract class AccessibilityService extends Service {
        ...
        //返回键
        public static final int GLOBAL_ACTION_BACK = 1;
        //Home键
        public static final int GLOBAL_ACTION_HOME = 2;
        //菜单键
        public static final int GLOBAL_ACTION_RECENTS = 3;
        ...
        public final boolean performGlobalAction(int action) {
            throw new RuntimeException("Stub!");
        }
        ...
    }
    

封装工具

了解完AccessibilityService后,可以对Accessibility进行简单的封装,方便使用。

  1. 获取root节点

    由上可知,在寻找目标的root节点这方面,getRootInActiveWindow()会根据有优势,所以

    /**
     * @return 获取root节点
     */
    private AccessibilityNodeInfo findRoot() {
       return getRootInActiveWindow();
    }
    
  2. 获取所有当前Activity可见节点

    遍历多叉树

    /**
     * 遍历多叉树 全遍历
     */
    private List<AccessibilityNodeInfo> iteratorTree(AccessibilityNodeInfo parent) {
        List<AccessibilityNodeInfo> childList = new ArrayList<>();
        if (parent == null) return childList;
        for (int i = 0; i < parent.getChildCount(); i++) {
            AccessibilityNodeInfo child = parent.getChild(i);
            childList.add(child);
            if (child.getChildCount() > 0) {
                //利用递归方法
                childList.addAll(iteratorTree(child));
            }
        }
        return childList;
    }
    

    获取全部可见节点

    /**
     * 遍历当前页面所有节点
     */
    private List<AccessibilityNodeInfo> findAllView() {
        AccessibilityNodeInfo parent = findRoot();
        List<AccessibilityNodeInfo> list = new ArrayList<>();
        list.add(parent);
        list.addAll(iteratorTree(parent));
        return list;
    }
    
  3. 查找第一个匹配节点

    马后炮:在Activity中,可能存在ListViewRecyclerView滑动控件使得目标节点出现超出屏幕情况,所以我们应当先在当前可见区域内查找是否有目标节点,如果没有,则向下滚动,再次查找目标节点——直至滑动到屏幕底部。为防止当前界面并非从最顶部开始向下滑动,故应该再向上滑动到顶部一次。

    查找滑动组件代码如下:

    private static final List<String> SCROLL_CLASS_NAME_ARRAY = Arrays.asList(
                "android.widget.ListView",
                "android.support.v7.widget.RecyclerView",
                "androidx.recyclerview.widget.RecyclerView"
    );
    /**
     * 查找可滑动的view
     */
    private List<AccessibilityNodeInfo> findCanScrollView() {
        List<AccessibilityNodeInfo> viewList = findAllView();
        List<AccessibilityNodeInfo> scrollViewList = new ArrayList<>();
        for (AccessibilityNodeInfo info : viewList) {
            if (info != null)
                //info.isScrollable判断是否为可滚动控件
                if (info.isScrollable()) {
                    //其中滚动控件还包含了spinner、viewpage等,而这些控件目前业务中并没有需要(且会影响业务内容),因此做了个限定
                    for (String scrollClassName : SCROLL_CLASS_NAME_ARRAY) {
                        if (scrollClassName.equals(info.getClassName().toString())) {
                            scrollViewList.add(info);
                            setLog("------------------------------------------------------------");
                            setLog("查询到可滑动组件" + info.getViewIdResourceName());
                            setLog("查询到可滑动组件" + info.getClassName());
                            setLog("------------------------------------------------------------");
                        }
                    }
                }
        }
        return scrollViewList;
    }
    

    遍历多叉树,根据条件查寻当前可见部分的节点

    /**
     * 遍历多叉树 查询方法
     * 找到控件会抛出异常,异常中包含目标数据
     */
    private void findIteratorTree(AccessibilityNodeInfo parent, String text) throws StopIteratorException {
        if (parent != null)
            for (int i = 0; i < parent.getChildCount(); i++) {
                AccessibilityNodeInfo child = parent.getChild(i);
                if (child != null)
                    if (multiCriteriaMatching(child, text)) {
                        throw new StopIteratorException(child);
                    } else if (child.getChildCount() > 0) {
                        findIteratorTree(child, text);
                    }
            }
    }
    
    /**
     * 自定义报错 用于中断递归
     */
    private static class StopIteratorException extends RuntimeException {
        private AccessibilityNodeInfo info;
        private StopIteratorException(AccessibilityNodeInfo info) {
            this.info = info;
        }
        private AccessibilityNodeInfo getInfo() {
            return info;
        }
    }
    
    /**
     * 多条件匹配 multiCriteriaMatching
     *
     * @param info 目前包含getViewIdResourceName、getClassName、getText
     * @param text 匹配词
     * @return 返回匹配结果
     */
    private boolean multiCriteriaMatching(AccessibilityNodeInfo info, String text) {
        return text.equals(info.getViewIdResourceName())
                || text.equals(info.getClassName() == null ? null : info.getClassName().toString())
                || text.equals(info.getText() == null ? null : info.getText().toString());
    }
    

    查找第一个匹配节点代码如下:

    /**
     * @param text 要查找目标组件的标识 (className/resourceId/text)
     * @return 返回查找到的目标组件 null则表示查找失败
     */
    private AccessibilityNodeInfo findFirst(String text) {
        AccessibilityNodeInfo info = null;
        try {
            findIteratorTree(findRoot(), text);
    
            int searchNum = 0;
            boolean scroll = true, isForward = true;
            List<AccessibilityNodeInfo> scrollViewList = new ArrayList<>();
            while (scroll) {//利用关键字scroll和Exception跳出循环
                //找不到控件,向下滑动继续找
                if (scrollViewList.size() == 0) {
                    if (searchNum < MAX_SEARCH_NUM) {
                        ++searchNum;
                        scrollViewList.addAll(findCanScrollView());
                    } else {
                        //找不到可滑动组件
                        setLog("找不到可滑动组件,结束循环体,查找次数:" + MAX_SEARCH_NUM);
                        scroll = false;
                    }
                }
    
                if (scrollViewList.size() != 0) {
                    //如果查找到滑动组件,则滑动滑动组件
                    //下、上各滑动一次,确保找到控件
                    for (AccessibilityNodeInfo scrollViewInfo : scrollViewList) {
                        if (isForward) {
                            if (!scrollViewInfo.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)) {
                                isForward = false;//滑动到底部,修改滑动状态,再滑一次
                            }
                        } else {
                            if (!scrollViewInfo.performAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)) {
                                scroll = false;
                            }
                        }
                    }
                }
                //滑动后 睡眠 之后查找组件
                SystemClock.sleep(TIME_SLEEP);
                findIteratorTree(findRoot(), text);
            }
        } catch (StopIteratorException e) {
            info = e.getInfo();
            if (info != null) {
                setLog("------------------------------------------------------------");
                setLog("查询到目标组件id:" + info.getViewIdResourceName());
                setLog("查询到目标组件text:" + info.getText());
                setLog("查询到目标组件clsName:" + info.getClassName());
                setLog("------------------------------------------------------------");
            }
        }
        return info;
    }
    

    补充说明:(可不看)在查看AccessibilityNodeInfo类中有如下方法

    class AccessibilityNodeInfo implements Parcelable{
        ...
        public List<AccessibilityNodeInfo> findAccessibilityNodeInfosByText(String text) {
            throw new RuntimeException("Stub!");
        }
    
        public List<AccessibilityNodeInfo> findAccessibilityNodeInfosByViewId(String viewId) {
            throw new RuntimeException("Stub!");
        }
        ...
    }
    

    由此联想到findIteratorTree(AccessibilityNodeInfo parent, String text)或许可以改成:(以下代码未尝试,只是临时想到的改进方案)

    private AccessibilityNodeInfo findIteratorTree(AccessibilityNodeInfo parent, String text){
        List<AccessibilityNodeInfo> infoList = new ArrayList<>();
        infoList.addAll(parent.findAccessibilityNodeInfosByText(text));
        if(info.size() == 0){
            infoList.addAll(parent.findAccessibilityNodeInfosByViewId(text));
        }
        AccessibilityNodeInfo info = null;
        if(infoList.size() > 0){
         info = infoList.get(0);
        }
        return info;
    }
    
  4. 点击事件

    找到目标节点后,自然是要对目标节点进行操作——这里仅封装点击事件

    /**
     * 点击事件
     *
     * @param info 要点击的目标View
     * @return 点击结果(成功/失败)
     */
    private boolean clickView(AccessibilityNodeInfo info) {
        if (info != null)
            if (info.isClickable()) {
                info.performAction(AccessibilityNodeInfo.ACTION_CLICK);
                return true;
            } else {
                AccessibilityNodeInfo parent = info.getParent();
                if (parent != null) {
                    boolean b = clickView(parent);
                    parent.recycle();
                    return b;
                }
            }
        return false;
    }
    

    将查询节点与点击节点组合起来就是:

    /**
     * 点击事件(点击本身)
     *
     * @param text 要点击的目标节点标识
     * @return 点击结果(true/false)
     */
    public boolean clickFirstView(String text) {
        AccessibilityNodeInfo info = findFirst(text);
        if (info == null) {
            setLog(text + "找不到匹配的控件");
            return false;
        }
        return clickView(info);
    }
    

    那如果是CheckBox之类的点击事件(如下图)应该怎么处理?


    1.jpg

    首先转化为查询该CheckBox的同级节点,再通过该同级节点反查父节点,之后再查询父节点中的子节点中包含isCheckable的节点:

    private AccessibilityNodeInfo findBrotherCheckBox(AccessibilityNodeInfo child) {
        AccessibilityNodeInfo checkBoxInfo = null;
        AccessibilityNodeInfo parent = child.getParent();
        if (parent != null) {
            List<AccessibilityNodeInfo> infoList = iteratorTree(parent);
            setLog("------------------------------------------------------------");
            for (AccessibilityNodeInfo info : infoList) {
                if (info.isCheckable()) {
                    checkBoxInfo = info;
                    setLog("------------------------------------------------------------");
                    setLog("查询到目标组件id:" + info.getViewIdResourceName());
                    setLog("查询到目标组件text:" + info.getText());
                    setLog("查询到目标组件clsName:" + info.getClassName());
                    setLog("------------------------------------------------------------");
                    break;
                }
            }
            setLog("------------------------------------------------------------");
        }
        return checkBoxInfo;
    }
    

    CheckBox节点中有isChecked字段用于判断是( true )否( false )选中,因此有:

    /**
     * checkBox的点击事件(带选中状态)
     *
     * @param brotherText 要点击目标的兄弟节点标识
     * @param setChecked  将checkBox的状态修改为true/false
     * @return 返回点击事件触发结果(true/false)
     */
    public boolean clickCheckBox(String brotherText, boolean setChecked) {
        AccessibilityNodeInfo brotherInfo = findFirst(brotherText);
        if (brotherInfo == null) {
            setLog(brotherText + "找不到匹配的控件");
            return false;
        }
        AccessibilityNodeInfo checkBoxInfo = findBrotherCheckBox(brotherInfo);
        if (checkBoxInfo == null) {
            setLog(brotherText + "找不到匹配的checkBox控件");
            return false;
        }
        if (checkBoxInfo.isChecked() != setChecked) {
            return clickView(checkBoxInfo);
        } else {
            setLog("checkBox控件为目标状态,不点击");
        }
        return false;
    }
    
  5. 补充

    静态变量、日志与返回

    //静态变量
    private static final int TIME_SLEEP = 300;//单位毫秒
    private static final int MAX_SEARCH_NUM = 20;//查找循环体次数
    
    //日志
    protected abstract boolean isDebugger();
    private void setLog(String msg) {
        if (isDebugger()) {
            String tag = "AManagerLog";
            Log.i(tag, msg);
        }
    }
    
    //系统返回键
    public void goBack() {
        performGlobalAction(GLOBAL_ACTION_BACK);
    }
    

    以上就是整个工具的封装了

如何使用

  1. 辅助功能触发点的设置

    辅助功能顾名思义,就是辅助用户使用当前App,甚至可以延伸为使用当前手机。比如:

    • 监测App接收到的通知消息,根据消息对手机进行辅助操作
    • 监测文件下载状态,根据下载状态进行自动安装、打开等操作
    • 当用户点击了某个位置时,根据点击的位置做出反馈
    • 为用户自动开启相关权限等
    • ...

    这时候:监测到App接收通知消息和监测到文件下载状态即为触发点;用户点击事件即为出发点;而开启权限,则可以是在辅助功能权限开启的时候。

    触发点为辅助功能权限开启:辅助功能权限开启的时候会调用初始化Service方法:onServiceConnected(),这里可以作为一个监测点,利用sendBroadcast(Intent intent)服务启动的消息广播出去。而广播接收器就是真实的触发点

    触发点为某种状态

    if(该状态可回调){
        if(辅助功能权限开启){
            直接执行辅助功能
        }else{
            弹出无障碍设置页面,引导用户开启辅助功能权限-触发点转为辅助功能权限开启时
        }
    }else{
        监测状态改变引发的界面变化
        ...
    }
    

    触发点为用户点击事件:如果点击的是自己的App,在点击事件触发辅助功能即可。如果点击的不为自己的App,可以在onAccessibilityEvent(AccessibilityEvent event)触发。

  2. 辅助功能执行

    辅助功能是一个富有想象力的功能,TA的执行能力取决于脑洞。比如模拟用户点击,让用户的操作更快捷(比如自动抢红包、一键连招等)或者取代繁琐操作(自动下载并安装等)甚至也可以是智能分词翻译(锤子BigBang)等等...

    这里以“取代繁琐操作”为例——征得用户同意后,替用户勾选一些无法直接申请的权限:

    任务:AssignmentEntity

    public class AssignmentEntity {
        private Queue<StepEntity> queue = new LinkedList();  //步骤队列
        private String name;                             //任务名称
    
        public AssignmentEntity() {
        }
    
        public String getName() {
            return this.name;
        }
    
        public AssignmentEntity setName(String name) {
            this.name = name;
            return this;
        }
    
        public Queue<StepEntity> getQueue() {
            return this.queue;
        }
    
        /**
         * 添加步骤
         **/
        public AssignmentEntity addStep(StepEntity entity) {
            this.queue.add(entity);
            return this;
        }
    }
    

    具体步骤:StepEntity

    public class StepEntity {
        public static final int TYPE_INTENT = 0;    //页面跳转
        public static final int TYPE_CLICK = 1;     //点击事件
        public static final int TYPE_CHECKED = 2;   //选中事件
        public static final int TYPE_BACK = 3;      //返回事件
        private String name;      //目标控件名称
        private int type;         //事件类型
        private boolean checked;  //选中事件-true:选中-false:反选
        private Intent intent;    //跳转事件-Intent
    
        public StepEntity() {
        }
    
        public String getName() {
            return this.name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
        public int getType() {
            return this.type;
        }
    
        public void setType(int type) {
            this.type = type;
        }
    
        public Intent getIntent() {
            return this.intent;
        }
    
        public void setIntent(Intent intent) {
            this.intent = intent;
        }
    
        public boolean isChecked() {
            return this.checked;
        }
    
        public void setChecked(boolean checked) {
            this.checked = checked;
        }
    }
    

    步骤辅助类StepHelper

    public class StepHelper {
        public StepHelper() {
        }
    
        /**
         * 跳转事件
         **/
        public static StepEntity intentStep(Intent intent) {
            return getStep("页面跳转", 0, intent, (Boolean)null);
        }
    
        /**
         * 点击事件
         **/
        public static StepEntity clickStep(String text) {
            return getStep(text, 1, (Intent)null, (Boolean)null);
        }
    
        /**
         * 选中事件
         **/
        public static StepEntity checkStep(String text, boolean setChecked) {
            return getStep(text, 2, (Intent)null, setChecked);
        }
    
        /**
         * 返回事件
         **/
        public static StepEntity backStep() {
            return getStep("返回上一页", 3, (Intent)null, (Boolean)null);
        }
    
        private static StepEntity getStep(String name, int type, @Nullable Intent intent, @Nullable Boolean setChecked) {
            StepEntity step = new StepEntity();
            step.setName(name);
            step.setType(type);
            if (0 == type) {
                if (intent == null) {
                    throw new IllegalArgumentException("代码错误,Type为TYPE_INTENT时Intent参数不能为空");
                }
    
                step.setIntent(intent);
            } else if (2 == type) {
                if (setChecked == null) {
                    throw new IllegalArgumentException("代码错误,Type为TYPE_CHECKED时setChecked参数不能为空");
                }
    
                boolean b = setChecked;
                step.setChecked(b);
            }
    
            return step;
        }
    }
    

    添加任务示例:

    public class AssignmentFactory {
        ...
        public static Queue<AssignmentEntity> create() {
            Queue<AssignmentEntity> queue = new LinkedList<>();
            if (RomUtils.isHuawei()) {
                queue.addAll(HuaweiFactory.create());
            } else if (RomUtils.isMiui()) {
                queue.addAll(XiaomiFactory.create());
            } else if (RomUtils.isOppo()) {
    
            } else if (RomUtils.isMeizu()) {
                queue.addAll(MeizuFactory.create());
            }
            return queue;
        }
        ...
            
        static class HuaweiFactory {
            
            private static int getVersion() {
                int version = -1;
                try {
                    String systemProperty = RomUtils.getSystemProperty("ro.build.version.emui");
                    if (systemProperty != null) {
                        String trim = systemProperty.replace("EmotionUI", "").replace("_", "").trim();
                        if (trim.contains(".")) {
                            trim = trim.substring(0, trim.indexOf("."));
                        }
                        version = Integer.valueOf(trim);
                    }
                } catch (Exception ignored) {
    
                }
                return version;
            }
    
            static Queue<AssignmentEntity> create() {
                L.i("手机版本号:" + getVersion()); //目前适配10
                Queue<AssignmentEntity> queue = new LinkedList<>();
                queue.add(ignoreBatteryOptimization());
                queue.addAll(newMessageNotification());
                queue.add(selfStarting());
                return queue;
            }
    
            //忽略电池优化
            private static AssignmentEntity ignoreBatteryOptimization() {
                AssignmentEntity assignment = new AssignmentEntity();
                assignment.setName("忽略电池优化");
    
                assignment.addStep(StepHelper.intentStep(IntentUtils.hightPowerManger()))
                        .addStep(StepHelper.clickStep("不允许"))
                        .addStep(StepHelper.clickStep("所有应用"))
                        .addStep(StepHelper.clickStep(BaseApplication.getInstance().getAppName()))
                        .addStep(StepHelper.clickStep("不允许"))
                        .addStep(StepHelper.clickStep("确定"))
                        .addStep(StepHelper.backStep());
    
                return assignment;
            }
    
            //新消息通知
            private static Queue<AssignmentEntity> newMessageNotification() {
                AssignmentEntity ae1 = new AssignmentEntity();
                ae1.setName("新消息通知");
                AssignmentEntity ae2 = new AssignmentEntity();
                ae2.setName("通知锁屏显示");
    
                ae1.addStep(StepHelper.intentStep(IntentUtils.huaweiNotification()))
                        .addStep(StepHelper.clickStep(BaseApplication.getInstance().getAppName()))
                        .addStep(StepHelper.checkStep("允许通知", true))
                        .addStep(StepHelper.backStep());
                ae2.addStep(StepHelper.clickStep("锁屏通知"))
                        .addStep(StepHelper.clickStep("显示所有通知"))
                        .addStep(StepHelper.clickStep("更多通知设置"))
                        .addStep(StepHelper.checkStep("通知亮屏提示", true))
                        .addStep(StepHelper.backStep())
                        .addStep(StepHelper.backStep());
    
                Queue<AssignmentEntity> queue = new LinkedList<>();
                queue.add(ae1);
                queue.add(ae2);
                return queue;
            }
    
            //自启动
            private static AssignmentEntity selfStarting() {
                AssignmentEntity assignment = new AssignmentEntity();
                assignment.setName("自启动");
    
                assignment.addStep(StepHelper.intentStep(IntentUtils.huaweiStartupNormalApp()))
                        .addStep(StepHelper.checkStep(BaseApplication.getInstance().getAppName(), true))
                        .addStep(StepHelper.checkStep(BaseApplication.getInstance().getAppName(), false))
                        .addStep(StepHelper.checkStep("允许自启动", true))
                        .addStep(StepHelper.checkStep("允许关联启动", true))
                        .addStep(StepHelper.checkStep("允许后台活动", true))
                        .addStep(StepHelper.clickStep("确定"))
                        .addStep(StepHelper.backStep());
    
                return assignment;
            }
        }
        
        ...
    }
    

    执行任务示例:

    public class AssignmentFactory {
        ...
        public static void run(Activity activity, Queue<AssignmentEntity> queue) {
            L.i("队列开始执行");
            int assignmentSize = queue.size();
    
            CompositeDisposable co = new CompositeDisposable();
            Disposable subscribe = Observable.create(
                    (ObservableOnSubscribe<AssignmentEntity>) emitter -> {
                        int progressSize = 0;
                        for (int i = 0; i < assignmentSize; i++) {
                            AssignmentEntity entity = queue.poll();
                            if (entity != null) {
                                Queue<StepEntity> stepQueue = entity.getQueue();
                                if (stepQueue != null) {
                                    int stepSize = stepQueue.size();
                                    for (int j = 0; j < stepSize; j++) {
                                        progressSize++;
                                        emitter.onNext(entity);
                                    }
                                }
                            }
                        }
                        //FloatWindowView.getInstance().progressBar.setMax(progressSize);
                        emitter.onComplete();
                    })
                    .subscribeOn(Schedulers.io())//执行在io线程
                    .observeOn(Schedulers.io())//回调在io线程  //主线程阻塞将无法更新ui AndroidSchedulers.mainThread()
                    .subscribe(
                            assignment -> {
                                //FloatWindowView.getInstance().progressAdd();
                                //L.i("当前进度:" + assignment.getName() + "===" + FloatWindowView.getInstance().getProgress());
                                poll(activity, assignment.getQueue().poll());
                            },
                            throwable -> L.e("", throwable),
                            () -> //FloatWindowView.getInstance().stopFloatWindow()
                    );
            co.add(subscribe);
        }
    
        private static void poll(Activity activity, StepEntity poll) {
            if (poll == null) return;
            switch (poll.getType()) {
                case StepEntity.TYPE_INTENT://跳转事件
                    activity.startActivity(poll.getIntent());
                    break;
                case StepEntity.TYPE_CLICK://点击事件
                    MyAccessibilityService.getInstance().clickFirstView(poll.getName());
                    break;
                case StepEntity.TYPE_CHECKED://选中事件
                    MyAccessibilityService.getInstance().clickCheckBox(poll.getName(), poll.isChecked());
                    break;
                case StepEntity.TYPE_BACK://返回事件
                    MyAccessibilityService.getInstance().goBack();
                    break;
            }
            SystemClock.sleep(POST_DELAY_MILLIS);
        }
        ...
    }
    

    链接

    完整demo:Github

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