Android 高级自定义Toast及源码解析

Posted 一口仨馍

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android 高级自定义Toast及源码解析相关的知识,希望对你有一定的参考价值。

本文已授权微信公众号《非著名程序员》原创首发,转载请务必注明出处。

Toast概述

Toast的作用

不需要和用户交互的提示框。

更多参见官网:https://developer.android.com/guide/topics/ui/notifiers/toasts.html

Toast的简单使用

    Toast.makeText(MainActivity.this.getApplicationContext(),"沉迷学习,日渐消瘦",Toast.LENGTH_SHORT).show()

自定义Toast

    Toast customToast = new Toast(MainActivity.this.getApplicationContext());
    View customView = LayoutInflater.from(MainActivity.this).inflate(R.layout.custom_toast,null);
    ImageView img = (ImageView) customView.findViewById(R.id.img);
    TextView tv = (TextView) customView.findViewById(R.id.tv);
    img.setBackgroundResource(R.drawable.daima);
    tv.setText("沉迷学习,日渐消瘦");
    customToast.setView(customView);
    customToast.setDuration(Toast.LENGTH_SHORT);
    customToast.setGravity(Gravity.CENTER,0,0);
    customToast.show();

布局文件中根元素为LinearLayout,垂直放入一个ImageView和一个TextView。代码就不贴了。

高级自定义Toast

产品狗的需求:点击一个Button,网络请求失败的情况下使用Toast的方式提醒用户。
程序猿:ok~大笔一挥。

Toast.makeText(MainActivity.this.getApplicationContext(),"沉迷学习,日渐消瘦",Toast.LENGTH_SHORT).show()

测试:你这程序写的有问题。每次点击就弹出了气泡,连续点击20次,居然花了一分多钟才显示完。改!
程序猿:系统自带的就这样。爱要不要。
测试:那我用单元测试模拟点击50次之后,它就不显示了,这个怎么说。
程序猿:…
这个时候,高级自定义Toast就要出场了~

activity_main.xml—->上下两个按钮,略。

MainActivity.java

public class MainActivity extends AppCompatActivity implements View.OnClickListener

    public static final String TAG = "MainActivity";
    private Button customToastBtn;
    private Button singleToastBtn;
    private static int num;

    @Override
    protected void onCreate(Bundle savedInstanceState) 
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initView();
        initClick();
        performClick(100);

    

    private void initView() 
        customToastBtn = (Button) findViewById(R.id.customToastBtn);
        singleToastBtn = (Button) findViewById(R.id.singleToastBtn);
    

    private void initClick() 
        customToastBtn.setOnClickListener(this);
        singleToastBtn.setOnClickListener(this);
    

    /**
     * 点击singleToastBtn按钮
     * @param clickFrequency 点击的次数
     */
    private void performClick(int clickFrequency) 
        for (int i = 0; i < clickFrequency; i++)
            singleToastBtn.performClick();
        
    

    @Override
    public void onClick(View view) 
        switch (view.getId())
            case R.id.customToastBtn:
                showCustomToast();
                break;
            case R.id.singleToastBtn:
                showSingleToast();
                break;
            default:break;
        
    

    private void showCustomToast() 
        Toast customToast = new Toast(MainActivity.this.getApplicationContext());
        View customView = LayoutInflater.from(MainActivity.this).inflate(R.layout.custom_toast,null);
        ImageView img = (ImageView) customView.findViewById(R.id.img);
        TextView tv = (TextView) customView.findViewById(R.id.tv);
        img.setBackgroundResource(R.drawable.daima);
        tv.setText("沉迷学习,日渐消瘦");
        customToast.setView(customView);
        customToast.setDuration(Toast.LENGTH_SHORT);
        customToast.setGravity(Gravity.CENTER,0,0);
        customToast.show();
    

    private void showSingleToast() 
        Toast singleToast = SingleToast.getInstance(MainActivity.this.getApplicationContext());
        View customView = LayoutInflater.from(MainActivity.this).inflate(R.layout.custom_toast,null);
        ImageView img = (ImageView) customView.findViewById(R.id.img);
        TextView tv = (TextView) customView.findViewById(R.id.tv);
        img.setBackgroundResource(R.drawable.daima);
        tv.setText("沉迷学习,日渐消瘦 第"+num+++"遍 toast="+singleToast);
        singleToast.setView(customView);
        singleToast.setDuration(Toast.LENGTH_SHORT);
        singleToast.setGravity(Gravity.CENTER,0,0);
        singleToast.show();
    

SingleToast.java

public class SingleToast 

    private static Toast mToast;

    /**双重锁定,使用同一个Toast实例*/
    public static Toast getInstance(Context context)
        if (mToast == null)
            synchronized (SingleToast.class)
                if (mToast == null)
                    mToast = new Toast(context);
                
            
        
        return mToast;
    

那么有的同学会问了:你这样不就是加了个单例吗,好像也没有什么区别。区别大了。仅仅一个单例,既实现了产品狗的需求,又不会有单元测试快速点击50次的之后不显示的问题。为什么?Read The Fucking Source Code。

Toast源码解析

这里以Toast.makeText().show为例,一步步追寻这个过程中源码所做的工作。自定义Toast相当于自己做了makeText()方法的工作,道理是一样一样的,这里就不再分别讲述了~

源码位置:frameworks/base/core/java/android/widght/Toast.java
Toast#makeText()

    public static Toast makeText(Context context, CharSequence text, @Duration int duration) 
        // 获取Toast对象
        Toast result = new Toast(context);
        LayoutInflater inflate = (LayoutInflater)
            context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);    
        // 填充布局
        View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
        TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
        tv.setText(text);
        // 设置View和duration属性
        result.mNextView = v;
        result.mDuration = duration;
        return result;
    

这里填充的布局transient_notification.xml位于frameworks/base/core/res/res/layout/transient_notification.xml。加分项,对于XML布局文件解析不太了解的同学可以看下这篇博客

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:background="?android:attr/toastFrameBackground">

    <TextView
        android:id="@android:id/message"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:layout_gravity="center_horizontal"
        android:textAppearance="@style/TextAppearance.Toast"
        android:textColor="@color/bright_foreground_dark"
        android:shadowColor="#BB000000"
        android:shadowRadius="2.75"
        />

</LinearLayout>

可以发现,里面只有一个TextView,平日设置的文本内容就是在这里展示。接下来只有一个show()方法,似乎我们的源码解析到这里就快结束了。不,这只是个开始

    public void show() 
        if (mNextView == null) 
            throw new RuntimeException("setView must have been called");
        
        INotificationManager service = getService();
        String pkg = mContext.getOpPackageName();
        TN tn = mTN;
        tn.mNextView = mNextView;
        try 
            service.enqueueToast(pkg, tn, mDuration);
         catch (RemoteException e) 
            // Empty
        
    

这里有三个问题。
1. 通过getService()怎么就获得一个INotificationManager对象?
2. TN类是个什么鬼?
3. 方法最后只有一个service.enqueueToast(),显示和隐藏在哪里?

Toast的精华就在这三个问题里,接下来的内容全部围绕上述三个问题,尤其是第三个。已经全部了解的同学可以去看别的博客了~

1. 通过getService()怎么就获得一个INotificationManager对象?

    static private INotificationManager getService() 
        if (sService != null) 
            return sService;
        
        sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
        return sService;
    

Binder机制了解的同学看见XXX.Stub.asInterface肯定会很熟悉,这不就是AIDL中获取client嘛!确实是这样。

tips: 本着追本溯源的精神,先看下ServiceManager.getService("notification")。在上上上上篇博客SystemServer启动流程源码解析startOtherServices()涉及到NotificationManagerService的启动,代码如下,这里不再赘述。

mSystemServiceManager.startService(NotificationManagerService.class);

ToastAIDL对应文件的位置。

源码位置:frameworks/base/core/java/android/app/INotificationManager.aidl

Server端:NotificationManagerService.java
源码位置:frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java

篇幅有限,这里不可能将AIDL文件完整的叙述一遍,不了解的同学可以理解为:经过进程间通信(AIDL方式),最后调用NotificationManagerService#enqueueToast()。具体可以看下这篇博客

2. TN类是个什么鬼?

Toast#makeText()中第一行就获取了一个Toast对象

    public Toast(Context context) 
        mContext = context;
        mTN = new TN();
        mTN.mY = context.getResources().getDimensionPixelSize(
                com.android.internal.R.dimen.toast_y_offset);
        mTN.mGravity = context.getResources().getInteger(
                com.android.internal.R.integer.config_toastDefaultGravity);
    

源码位置:frameworks/base/core/java/android/widght/Toast$TN.java

    private static class TN extends ITransientNotification.Stub 
        ...
        TN() 
            final WindowManager.LayoutParams params = mParams;
            params.height = WindowManager.LayoutParams.WRAP_CONTENT;
            params.width = WindowManager.LayoutParams.WRAP_CONTENT;
            ...
        
        ...
    

源码中的进程间通信实在太多了,我不想说这方面的内容啊啊啊~。有时间专门再写一片博客。这里提前剧透下TN类除了设置参数的作用之外,更大的作用是Toast显示与隐藏的回调。TN类在这里作为Server端。NotificationManagerService$NotificationListeners类作为client端。这个暂且按下不提,下文会详细讲述。

3. show()方法最后只有一个service.enqueueToast(),显示和隐藏在哪里?

源码位置:frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java

    private final IBinder mService = new INotificationManager.Stub() 

        @Override
        public void enqueueToast(String pkg, ITransientNotification callback, int duration)
        
            if (pkg == null || callback == null) 
                Slog.e(TAG, "Not doing toast. pkg=" + pkg + " callback=" + callback);
                return ;
            
            final boolean isSystemToast = isCallerSystem() || ("android".equals(pkg));
            ...
            synchronized (mToastQueue) 
                int callingPid = Binder.getCallingPid();
                long callingId = Binder.clearCallingIdentity();
                try 
                    ToastRecord record;
                    int index = indexOfToastLocked(pkg, callback);
                    if (index >= 0) 
                        record = mToastQueue.get(index);
                        record.update(duration);
                     else 
                        if (!isSystemToast) 
                            int count = 0;
                            final int N = mToastQueue.size();
                            for (int i=0; i<N; i++) 
                                 final ToastRecord r = mToastQueue.get(i);
                                 if (r.pkg.equals(pkg)) 
                                     count++;
                                     if (count >= MAX_PACKAGE_NOTIFICATIONS) 
                                         Slog.e(TAG, "Package has already posted " + count
                                                + " toasts. Not showing more. Package=" + pkg);
                                         return;
                                     
                                 
                            
                        

                        record = new ToastRecord(callingPid, pkg, callback, duration);
                        mToastQueue.add(record);
                        index = mToastQueue.size() - 1;
                        // 将Toast所在的进程设置为前台进程
                        keepProcessAliveLocked(callingPid);
                    
                    if (index == 0) 
                        showNextToastLocked();
                    
                 finally 
                    Binder.restoreCallingIdentity(callingId);
                
            
        
        ...
    

Toast#show()最终会进入到这个方法。首先通过indexOfToastLocked()方法获取应用程序对应的ToastRecordmToastQueue中的位置,Toast消失后返回-1,否则返回对应的位置。mToastQueue明明是个ArratList对象,却命名Queue,猜测后面会遵循“后进先出”的原则移除对应的ToastRecord对象~。这里先以返回index=-1查看,也就是进入到else分支。如果不是系统程序,也就是应用程序。那么同一个应用程序瞬时mToastQueue中存在的消息不能超过50条(Toast对象不能超过50个)。否则直接return。这也是上文中为什么快速点击50次之后无法继续显示的原因。既然瞬时Toast不能超过50个,那么运用单例模式使用同一个Toast对象不就可以了嘛?答案是:可行。消息用完了就移除,瞬时存在50个以上的Toast对象相信在正常的程序中也用不上。而且注释中也说这样做是为了放置DOS攻击和防止泄露。其实从这里也可以看出:为了防止内存泄露,创建Toast最好使用getApplicationContext,不建议使用ActivityService等。

回归主题。接下来创建了一个ToastRecord对象并添加进mToastQueue。接下来调用showNextToastLocked()方法显示一个Toast

源码位置:frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java
NotificationManagerService#showNextToastLocked()

    void showNextToastLocked() 
        ToastRecord record = mToastQueue.get(0);
        while (record != null) 
            if (DBG) Slog.d(TAG, "Show pkg=" + record.pkg + " callback=" + record.callback);
            try 
                record.callback.show();
                scheduleTimeoutLocked(record);
                return;
             catch (RemoteException e) 
                int index = mToastQueue.indexOf(record);
                if (index >= 0) 
                    mToastQueue.remove(index);
                
                keepProcessAliveLocked(record.pid);
                if (mToastQueue.size() > 0) 
                    record = mToastQueue.get(0);
                 else 
                    record = null;
                
            
        
    

这里首先调用record.callback.show(),这里的record.callback其实就是TN类。接下来调用scheduleTimeoutLocked()方法,我们知道Toast显示一段时间后会自己消失,所以这个方法肯定是定时让Toast消失。跟进。

    private void scheduleTimeoutLocked(ToastRecord r)
    
        mHandler.removeCallbacksAndMessages(r);
        Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r);
        long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
        mHandler.sendMessageDelayed(m, delay);
      

果然如此。重点在于使用mHandler.sendMessageDelayed(m, delay)延迟发送消息。这里的delay只有两种值,要么等于LENGTH_LONG,其余统统的等于SHORT_DELAYsetDuration为其他值用正常手段是没有用的(可以反射,不在重点范围内)。
handler收到MESSAGE_TIMEOUT消息后会调用handleTimeout((ToastRecord)msg.obj)。跟进。

    private void handleTimeout(ToastRecord record)
    
        if (DBG) Slog.d(TAG, "Timeout pkg=" + record.pkg + " callback=" + record.callback);
        synchronized (mToastQueue) 
            int index = indexOfToastLocked(record.pkg, record.callback);
            if (index >= 0) 
                cancelToastLocked(index);
            
        
    

啥也不说了,跟进吧~

    void cancelToastLocked(int index) 
        ToastRecord record = mToastQueue.get(index);
        try 
            record.callback.hide();
         catch (RemoteException e) 
            ...
        
        mToastQueue.remove(index);
        keepProcessAliveLocked(record.pid);
        if (mToastQueue.size() > 0) 
            showNextToastLocked();
        
    

延迟调用record.callback.hide()隐藏Toast,前文也提到过:record.callback就是TN对象。到这,第三个问题已经解决一半了,至少我们已经直到Toast的显示和隐藏在哪里被调用了,至于怎么显示怎么隐藏的,客观您接着往下看。

源码位置:frameworks/base/core/java/android/widght/Toast TN.javaToast TN#show()

        final Handler mHandler = new Handler(); 

        @Override
        public void show() 
            if (localLOGV) Log.v(TAG, "SHOW: " + this);
            mHandler.post(mShow);
        

        final Runnable mShow = new Runnable() 
            @Override
            public void run() 
                handleShow();
            
        ;

注意下这里直接使用new Handler获取Handler对象,这也是为什么在子线程中不用Looper弹出Toast会出错的原因。跟进handleShow()

        public void handleShow() 
            if (mView != mNextView) 
                // remove the old view if necessary
                handleHide();
                mView = mNextView;
                Context context = mView.getContext().getApplicationContext();
                String packageName = mView.getContext().getOpPackageName();
                if (context == null) 
                    context = mView.getContext();
                
                mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
                ...
                mParams.packageName = packageName;
                if (mView.getParent() != null) 
                    mWM.removeView(mView);
                
                mWM.addView(mView, mParams);
                trySendAccessibilityEvent();
            
        

原来addViewWindowManager。这样就完成了Toast的显示。至于隐藏就更简单了。

        public void handleHide() 
            if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
            if (mView != null) 
                // note: checking parent() just to make sure the view has
                // been added...  i have seen cases where we get here when
                // the view isn't yet added, so let's try not to crash.
                if (mView.getParent() != null) 
                    if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                    mWM.removeView(mView);
                

                mView = null;
            
        

直接remove掉。

题外话

今天周末,一天时间完成这篇简单的源码阅读加写作。每次写完源码解析总是成就感伴随着失落感, 成就感来源于我又get到一个原理或者新技能,失落感来自源码也就是那么回事,但是回头想想我得到了什么?其实并不多。但我仍然在乐此不疲的追寻着。或许是我还没“开窍”,没有到那种融会贯通的境界。但我清楚的知道,我在进步。我在努力变的更加优秀。


更多Framework源码解析,请移步 Android6.0 Framework源码解析系列[目录]

以上是关于Android 高级自定义Toast及源码解析的主要内容,如果未能解决你的问题,请参考以下文章

Android中的Toast源码分析和自定义Toast

android的toast怎么自定义显示时间长度?

Toast的高级自定义方式-循序渐进带你了解toast

Toast源码分析(Android 11)

android高级UI布局层次结构及布局加载流程源码解析

android高级UI布局层次结构及布局加载流程源码解析