Android项目中Loading对话框的优化

1.ContentLoadingProgressBar介绍

最近在学习开源项目的时候偶然看到了ContentLoadingProgressBar这个控件,此前我没有接触过,就想着了解一下它的功能。从名称上看,ContentLoadingProgressBar应该和ProgressBar有着什么联系,项目中也是把它当做ProgressBar来使用的,点进源码一看,果然ContentLoadingProgressBar是继承自ProgressBar的。

public class ContentLoadingProgressBar extends ProgressBar {
    // ...
}

既然是继承自ProgressBar,那么肯定是在ProgressBar的基础上添加了特殊的功能,先来看一下类的注释:

/**
 * ContentLoadingProgressBar implements a ProgressBar that waits a minimum time to be
 * dismissed before showing. Once visible, the progress bar will be visible for
 * a minimum amount of time to avoid "flashes" in the UI when an event could take
 * a largely variable time to complete (from none, to a user perceivable amount)
 */

从注释中可以看出,ContentLoadingProgressBar在ProgressBar的基础上添加了以下特性:

  • 在显示之前会等待一段时间来被隐藏
  • 一旦显示,ContentLoadingProgressBar会在一段时间内都保持可见

这两个特性的共同作用就是避免UI视图的“闪烁”现象,这是什么意思呢,相信大家在项目开发中都遇到过这样一种情况,在进行网络请求之前显示Loading对话框,请求完成之后再隐藏,如果网络请求耗时很短,那么就会导致对话框在短时间内显示和隐藏,造成“闪烁”现象,如下图所示:

结合上述场景,ContentLoadingProgressBar的这两个特性就很好理解了,首先在显示之前等待一段时间(当然这段时间很短,否则会产生卡顿现象),如果在这段时间内被隐藏,那么就不会显示出ContentLoadingProgressBar。此外,一旦显示出了ContentLoadingProgressBar,还要保证其显示时间不能太短,否则同样会造成“闪烁”现象。在这两点的共同作用下就不会出现ContentLoadingProgressBar刚显示就被隐藏的问题了,从而避免了“闪烁”现象。
清楚了ContentLoadingProgressBar的特性和作用后我们来简单看一下它是如何实现的,完整代码如下:

public class ContentLoadingProgressBar extends ProgressBar {
    private static final int MIN_SHOW_TIME = 500; // ms
    private static final int MIN_DELAY = 500; // ms

    long mStartTime = -1; // 开始显示时的时间

    boolean mPostedHide = false;

    boolean mPostedShow = false;

    boolean mDismissed = false;

    private final Runnable mDelayedHide = new Runnable() {

        @Override
        public void run() {
            mPostedHide = false;
            mStartTime = -1;
            setVisibility(View.GONE);
        }
    };

    private final Runnable mDelayedShow = new Runnable() {

        @Override
        public void run() {
            mPostedShow = false;
            if (!mDismissed) {
                mStartTime = System.currentTimeMillis();
                setVisibility(View.VISIBLE);
            }
        }
    };

    public ContentLoadingProgressBar(@NonNull Context context) {
        this(context, null);
    }

    public ContentLoadingProgressBar(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs, 0);
    }

    @Override
    public void onAttachedToWindow() {
        super.onAttachedToWindow();
        removeCallbacks();
    }

    @Override
    public void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        removeCallbacks();
    }

    private void removeCallbacks() {
        removeCallbacks(mDelayedHide);
        removeCallbacks(mDelayedShow);
    }

    /**
     * Hide the progress view if it is visible. The progress view will not be
     * hidden until it has been shown for at least a minimum show time. If the
     * progress view was not yet visible, cancels showing the progress view.
     */
    public synchronized void hide() {
        mDismissed = true;
        removeCallbacks(mDelayedShow);
        mPostedShow = false;
        long diff = System.currentTimeMillis() - mStartTime;
        if (diff >= MIN_SHOW_TIME || mStartTime == -1) {
            // ContentLoadingProgressBar的显示时间已经超过了500ms或者还没有显示
            setVisibility(View.GONE);
        } else {
            // ContentLoadingProgressBar的显示时间不足500ms
            if (!mPostedHide) {
                postDelayed(mDelayedHide, MIN_SHOW_TIME - diff);
                mPostedHide = true;
            }
        }
    }

    /**
     * Show the progress view after waiting for a minimum delay. If
     * during that time, hide() is called, the view is never made visible.
     */
    public synchronized void show() {
        // Reset the start time.
        mStartTime = -1;
        mDismissed = false;
        removeCallbacks(mDelayedHide);
        mPostedHide = false;
        if (!mPostedShow) {
            postDelayed(mDelayedShow, MIN_DELAY);
            mPostedShow = true;
        }
    }
}

ContentLoadingProgressBar中定义了两个int类型的常量MIN_SHOW_TIMEMIN_DELAY,分别表示显示的最短时间和延迟显示的时间,值都是500ms。mDelayedShowmDelayedHide是两个Runable任务,分别对应延时显示和延时隐藏。在控制ContentLoadingProgressBar的显示和隐藏时不能使用setVisibility()方法,这样就和使用ProgressBar没有区别了,而是需要使用show()hide()方法,我们来分别看一下这两个方法。
首先是show()方法,这里首先会做一些状态的恢复处理,将mStartTime恢复为-1,mStartTime记录了ContentLoadingProgressBar开始显示的时间,接着将延时隐藏任务mDelayedHide从任务队列中移除。方法最后会判断mPostedShow的值,如果为false就调用postDelayed()方法延迟MIN_DELAY(500ms)后执行mDelayedShow任务。mPostedShow用于标记mDelayedShow是否已添加到任务队列中,防止任务的重复执行。mDelayedShow任务的逻辑很简单,主要就是记录开始显示的时间并执行setVisibility(View.VISIBLE)将ContentLoadingProgressBar显示出来。
我们再来看hide()方法,和show()方法类似,首先将延时显示任务mDelayedShow从任务队列中移除,因此如果调用show()hide()方法之间的间隔时间小于MIN_DELAY(500ms),mDelayedShow就不会执行了,ContentLoadingProgressBar也就不会显示了。接下来会计算System.currentTimeMillis() - mStartTime的值,即此时ContentLoadingProgressBar的显示时间,如果此时mStartTime的值为-1(ContentLoadingProgressBar还没有显示)或者显示时间超过了MIN_SHOW_TIME(500ms),直接执行setVisibility(View.GONE)隐藏ContentLoadingProgressBar;反之则说明ContentLoadingProgressBar的显示时间没有达到最短时间500ms,计算剩余的时间,延时执行隐藏任务,保证ContentLoadingProgressBar最短可以显示500ms。这里的mPostedHide作用同样是防止延时隐藏任务的重复执行。mDelayedHide任务的逻辑也比较简单,将mStartTime恢复为-1,执行setVisibility(View.GONE)隐藏ContentLoadingProgressBar。
ContentLoadingProgressBar实现的基本原理还是比较简单的,看到这里不知道大家是否和我一样受到了启发呢,我们是不是也可以仿照ContentLoadingProgressBar来定义一个Loading对话框,解决“闪烁”问题呢?

2.Loading对话框的优化

ContentLoadingProgressBar给了我们很好的思路,解决Loading对话框“闪烁”问题需要做到以下两点:

  • 显示Loading对话框之前先等待一段时间
  • 隐藏Loading对话框时判断显示时间是否达到了最短显示时间,如果没有达到就延时执行隐藏任务

清楚思路后就可以优化Loading对话框了,直接附上完整代码:

import android.app.AlertDialog;
import android.content.Context;
import android.os.Handler;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;

import androidx.annotation.NonNull;

public class LoadingDialog extends AlertDialog {

    private static final int MIN_SHOW_TIME = 500;
    private static final int MIN_DELAY = 500;

    private TextView tvMessage;

    private long mStartTime = -1;
    private boolean mPostedHide = false;
    private boolean mPostedShow = false;
    private boolean mDismissed = false;

    private Handler mHandler = new Handler();

    private final Runnable mDelayedHide = new Runnable() {

        @Override
        public void run() {
            mPostedHide = false;
            mStartTime = -1;
            dismiss();
        }
    };

    private final Runnable mDelayedShow = new Runnable() {

        @Override
        public void run() {
            mPostedShow = false;
            if (!mDismissed) {
                mStartTime = System.currentTimeMillis();
                show();
            }
        }
    };

    public LoadingDialog(@NonNull Context context) {
        super(context, R.style.Theme_AppCompat_Dialog);
        View loadView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_loading, null);
        setView(loadView);
        tvMessage = loadView.findViewById(R.id.tv_message);
    }

    public void showDialog(String message) {
        tvMessage.setText(message);

        mStartTime = -1;
        mDismissed = false;
        mHandler.removeCallbacks(mDelayedHide);
        mPostedHide = false;
        if (!mPostedShow) {
            mHandler.postDelayed(mDelayedShow, MIN_DELAY);
            mPostedShow = true;
        }
    }

    public void hideDialog() {
        mDismissed = true;
        mHandler.removeCallbacks(mDelayedShow);
        mPostedShow = false;
        long diff = System.currentTimeMillis() - mStartTime;
        if (diff >= MIN_SHOW_TIME || mStartTime == -1) {
            dismiss();
        } else {
            if (!mPostedHide) {
                mHandler.postDelayed(mDelayedHide, MIN_SHOW_TIME - diff);
                mPostedHide = true;
            }
        }
    }
  
    @Override
    public void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mHandler.removeCallbacks(mDelayedHide);
        mHandler.removeCallbacks(mDelayedShow);
    }
}

布局文件我就不展示了,就是一个ProgressBar和一个TextView,用于展示提示信息。其实基本上都是照抄的ContentLoadingProgressBar,区别只是需要定义一个Handler对象来处理延时任务。控制Loading对话框的显示和隐藏直接使用showDialog()hideDialog()方法就可以了。为了简单示例,我这里自定义的Dialog直接继承自AlertDialog,大家项目中使用的可能是自己定义的Dialog或者第三方Dialog,又或者是DialogFragment,都没关系,只需要清楚思路,自行修改一下即可,注意要在适当的时机移除延时任务,防止内存泄漏。
优化完成后我们可以简单地测试一下,添加两个按钮,点击按钮时调用showDialog()方法延时显示Loading对话框,之后分别延时300ms和600ms后调用hideDialog()方法隐藏Loading对话框,模拟网络请求过程,运行效果如下图所示:

可以看出,延时300ms的情况由于调用显示和隐藏方法的间隔时间小于MIN_DELAY,因此不会显示出Loading对话框;延时600ms的情况会显示出Loading对话框,由于调用hideDialog()方法时Loading对话框显示的时间大约为600 - MIN_DELAY = 100ms不足MIN_SHOW_TIME,因此会延时显示一段时间后再隐藏。
补充一下,我这里定义的Loading对话框的延时显示时间和最短显示时间都是使用的500ms,和ContentLoadingProgressBar一样,大家也可以修改成自己认为合适的值,尤其是延时显示时间,500ms可能有些长,容易给用户造成卡顿的感觉,可以适当地减小延时时间,比如调整为300ms。

3.总结

本文通过分析ContentLoadingProgressBar的原理引出了项目开发中Loading对话框的一种优化方式,避免对话框显示和隐藏间隔时间太短导致的“闪烁”现象。其实这可能也不算什么问题,不做处理也没关系,但既然解决起来很简单,又能给用户带来更好的使用体验,为什么不去做呢。提到优化我们往往想到的都是运行性能、内存等等方面,代码逻辑上的优化很容易被忽略,但恰恰这才是我们要首先考虑也是最容易着手的。最后,限于自身水平,文中有些地方可能分析得不是很准确,或者大家有什么更好的想法都欢迎提出,一起交流。

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