《Android开发艺术探索》第10章-Android 的消息机制读书笔记

1. Android 的消息机制概述

1.1 Android 的消息机制是什么?

Android 的消息机制是通过 Handler 的运行机制来实现将一个任务切换到 Handler 所在的线程中去执行。

但是,完成把一个任务切换到 Handler 所在的线程中去执行这个事情,单靠 Handler 类是不行的;实际上,Handler 的运行需要 MessageQueueLooper 的支撑,Handler 是作为 Android 消息机制的上层接口而已。

换句话说,Android 定义了Handler 直接面向了开发者,屏蔽了 MessageQueueLooper(没有完全屏蔽 Looper),开发者只需要和 Handler 打交道就可以运用 Android 的消息机制了。

1.2 Handler 就是专门用来更新 UI 的,这种说法对吗?为什么?

不对。

在开发过程中,我们在子线程中执行一些耗时的操作,比如读取文件,读取数据库,访问网络等,拿到我们需要的数据,然后把这些数据显示在 UI 上。这时,直接在子线程中操作 UI 控件来显示数据,Android 是不允许的,会抛出异常给我们的;正确的做法是,在 UI 线程创建一个 Handler 对象,在子线程中使用这个 Handler 对象将要显示的数据切换到 Handler 所在的 UI 线程,再操作 UI 控件来显示数据。这就是 Handler 用来更新 UI 的场景了。

来看看实际的代码吧:

// 在主线程创建 Handler 对象
private Handler mainThreadHandler = new Handler(){
    @Override
    public void handleMessage(Message msg) {
        super.handleMessage(msg);
        if (msg.what == 3) {
            Log.d(TAG, "handleMessage: msg.what=" + msg.what + ",msg.obj=" +
                    msg.obj + ",threadName=" + Thread.currentThread().getName());
            // 这里是主线程,可以放心更新 UI 了。
        }
    }
};
// 点击按钮从子线程发送消息到主线程
public void sendMessage2UIThread(View view) {
	// 开启一个子线程
    new Thread(new Runnable() {
        @Override
        public void run() {
            int what = 3;
            String obj = "hello, ui thread!";
            Log.d(TAG, "sendMessage2UIThread: what="+ what +",obj=" +
                    obj + ",threadName=" + Thread.currentThread().getName());
            mainThreadHandler.obtainMessage(what, obj).sendToTarget();
        }
    }, "work-thread").start();
}

打印日志如下:

D/MainActivity: sendMessage2UIThread: what=3,obj=hello, ui thread!,threadName=work-thread
D/MainActivity: handleMessage: msg.what=3,msg.obj=hello, ui thread!,threadName=main

但是,我们还可以把数据从主线程切换到子线程中去执行。这里使用实际的例子来进行说明:

private Handler workThreadHandler;
private void startWorkThread() {
	// 开启一个子线程
    new Thread(new Runnable() {
        @Override
        public void run() {
            Looper.prepare();
            // 在子线程中创建 Handler 对象
            workThreadHandler = new Handler() {
                @Override
                public void handleMessage(Message msg) {
                    super.handleMessage(msg);
                    if (msg.what == 2) {
                        Log.d(TAG, "handleMessage: msg.what=" + msg.what + ",msg.obj=" + 
                                msg.obj + ",threadName=" + Thread.currentThread().getName());
                    }
                }
            };
            Looper.loop();
        }
    }, "work-thread").start();
}
// 点击按钮从主线程发送消息到子线程
public void sendMessage2WorkThread(View view) {
    int what = 2;
    String obj = "hello, work thread!";
    Log.d(TAG, "sendMessage2WorkThread: what="+ what +",obj=" + 
            obj + ",threadName=" + Thread.currentThread().getName());
    workThreadHandler.sendMessage(workThreadHandler.obtainMessage(what, obj));
}

点击按钮,打印日志如下:

D/MainActivity: sendMessage2WorkThread: what=2,obj=hello, work thread!,threadName=main
D/MainActivity: handleMessage: msg.what=2,msg.obj=hello, work thread!,threadName=work-thread

可以看到,这里确实实现了把数据从主线程切换到子线程中了。

因此,我们说,Handler 并非是专门用来更新 UI 的,只是常被开发者用来更新 UI 而已。

1.3 在子线程真的不能更新 UI 吗?

我们知道,Android 规定访问 UI 要在主线程中进行,如果在子线程中更新 UI,程序就会抛出异常。这是因为在 ViewRootImpl 类中会对 UI 做验证,具体来说是由 checkThread 方法来完成的。

void checkThread() {
    if (mThread != Thread.currentThread()) {
        throw new CalledFromWrongThreadException(
                "Only the original thread that created a view hierarchy can touch its views.");
    }
}

现在我们在子线程里去给 TextView 控件设置文本:

private TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_check_thread_not_working);
    tv = (TextView) findViewById(R.id.tv);
    new Thread(() -> {
        SystemClock.sleep(1000L);
        tv.setText("I am text set in " + Thread.currentThread().getName());
    },"work-thread").start();
}

运行程序,会报错:

2022-01-08 05:47:15.391 9225-9252/com.wzc.chapter_10 E/AndroidRuntime: FATAL EXCEPTION: work-thread
    Process: com.wzc.chapter_10, PID: 9225
    android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:6357)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:874)
        at android.view.View.requestLayout(View.java:17476)
        at android.view.View.requestLayout(View.java:17476)
        at android.view.View.requestLayout(View.java:17476)
        at android.view.View.requestLayout(View.java:17476)
        at android.view.View.requestLayout(View.java:17476)
        at android.widget.TextView.checkForRelayout(TextView.java:6871)
        at android.widget.TextView.setText(TextView.java:4057)
        at android.widget.TextView.setText(TextView.java:3915)
        at android.widget.TextView.setText(TextView.java:3890)
        at com.wzc.chapter_10.CheckThreadNotWorkingActivity.lambda$onCreate$0$CheckThreadNotWorkingActivity(CheckThreadNotWorkingActivity.java:19)
        at com.wzc.chapter_10.-$$Lambda$CheckThreadNotWorkingActivity$Thy_KGiEr_duYPMycxt-0lYIEGo.run(lambda)
        at java.lang.Thread.run(Thread.java:818)

可以看到正是在 ViewRootImpl 类的 checkThread 方法里面抛出了异常:

Only the original thread that created a view hierarchy can touch its views.

checkThread 方法里的 mThread 就是 UI 线程,现在我们在子线程里面调用了 checkThread 方法,则 Thread.currentThread() 就是子线程,这样 mThread != Thread.currentThread() 判断就为 true,会进入 if 分支,抛出 CalledFromWrongThreadException 异常。

但是,如果我把 SystemClock.sleep(1000L); 这行代码注释掉会怎么样呢?

运行程序,效果如下:
在这里插入图片描述
是的,这不是幻觉,在子线程更新 UI 成功了。

那么,问题又来了,为什么有休眠时在子线程更新 UI 报错,而不休眠时在子线程更新 UI 成功呢?

这是因为有休眠时,在执行更新 UI 操作时,ViewRootImpl 对象已经创建成功了,就会执行到 checkThread 方法了;没有休眠时,在执行更新 UI 操作时, ViewRootImpl 对象还未创建,就没有执行到 checkThread 方法了。

实际上,我们这里不加休眠的情况下,只是在子线程设置文本时没有走 checkThread 方法而已,等到真正把文本绘制到屏幕上,仍然是在 UI 线程进行的。

再看一下这个方法,

void checkThread() {
    if (mThread != Thread.currentThread()) {
        throw new CalledFromWrongThreadException(
                "Only the original thread that created a view hierarchy can touch its views.");
    }
}

只要 mThreadThread.currentThread() 相同就不会报异常,并且异常的中文含义:只有原来创建了视图体系的线程才可以操作它的 View。这根本没有说不让子线程更新 UI。这里真正想说明的意思是:哪个线程创建了视图体系,就要由那个线程来操作它的 View;换句话说,如果某个线程去操作另外一个线程创建的 View,那是不允许的。

那么,如果我们就在子线程中去完成视图的添加,这会有问题吗?

我们在子线程里面去添加一个 Window,代码如下:

public void createUIInWorkThread(View view) {
    new Thread(() -> {
        // 这里要由 Looper 对象,因为在 ViewRootImpl 里面会创建 ViewRootHandler 对象。
        Looper.prepare();
        TextView tv = new TextView(MainActivity.this);
        tv.setBackgroundColor(Color.GRAY);
        tv.setTextColor(Color.RED);
        tv.setTextSize(40);
        tv.setText("i am text created in " + Thread.currentThread().getName());
        WindowManager windowManager = MainActivity.this.getWindowManager();
        WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
                WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
                0, 0, PixelFormat.TRANSPARENT);
        layoutParams.type = WindowManager.LayoutParams.TYPE_TOAST;
        layoutParams.gravity = Gravity.CENTER;
        layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
        windowManager.addView(tv, layoutParams);
        Looper.loop();
    }, "work-thread").start();
}

点击按钮,查看效果:
在这里插入图片描述
可以看到,在子线程里里面操作 UI 是可以的。

这里我们总结一下:

  • 在线程 A 里面一般不能操作线程 B 的 UI;但是如果线程 B 的 ViewRootImpl 还没有创建,这时就不会走 checkThread 方法,也不会抛出异常,最终仍是由线程 B 完成了 UI 的操作。

  • 在一个线程里操作由这个线程自己创建的视图体系是可以的,也可以说,一个线程只可以操作它自己的 UI。

1.4 Android 系统为什么使用单线程模型来访问 UI?

Android 的 UI 控件不是线程安全的,如果在多线程中并发访问可能会导致 UI 控件处于不可预期的状态;而如果对 UI 控件的访问加上锁机制,会让 UI 访问的逻辑变得复杂,也会降低 UI 访问的效率。

所以,Android 采用单线程模型才处理 UI 操作。

1.5 为什么说 Handler 类是 Android 消息机制的上层接口?

从构造方法来看

分为两大类:可以传递 Looper 对象的和不可以传递 Looper 对象的。

在这里插入图片描述

我们重点看不传递 Looper 对象的 Handler(Callback callback, boolean async) 方法,因为这个方法非常具有代表性。

public Handler(Callback callback, boolean async) {
    mLooper = Looper.myLooper();
    if (mLooper == null) {
        throw new RuntimeException(
            "Can't create handler inside thread that has not called Looper.prepare()");
    }
    mQueue = mLooper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}

这个方法的主要作用:

  • 通过 Looper.myLooper() 方法来获取并通过 mLooper持有 Looper 对象。Looper.myLooper() 方法会从线程本地变量 ThreadLocal里面取出与当前线程对应的 Looper 对象。
  • 如果 mLooper 对象仍为 null,就会抛出异常:“Can't create handler inside thread that has not called Looper.prepare()”,这是告诉当前线程没有调用 Looper.prepare(),所以不能创建 Handler
  • 通过 Looper 对象获取 MessageQueue 对象并赋值给 mQueue 成员变量。

在构造 Handler 对象时,就一定要持有 Looper 对象和 MessageQueue 对象,也就是说,Handler 类组合了 Looper 对象和 MessageQueue 对象。

从发送消息方法来看

发送消息的方法分为两大类:postXXX 方法和 sendXXX 方法。

postXXX 方法用于发送一个 Runnable 对象,它会包装成一个 Message 对象,再发送到消息队列中;

sendXXX 方法用于发送一个 Message 对象到消息队列中。

在这里插入图片描述

从调用图可以看到,不管是 postXXX 方法和 sendXXX 方法,最终调用的都是 enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) 方法:

private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
    msg.target = this;
    if (mAsynchronous) {
        msg.setAsynchronous(true);
    }
    return queue.enqueueMessage(msg, uptimeMillis);
}

这个方法的主要作用:

  • Handler 对象赋值给 Message 对象的 target 字段;
  • 调用 MessageQueue 对象的 enqueueMessage 方法把消息加入到消息队列。

从处理消息方法来看

当消息从消息队列中取出时,会调用 Handler 对象的 dispatchMessage 方法来分发消息

public void dispatchMessage(Message msg) {
    if (msg.callback != null) {
        handleCallback(msg);
    } else {
        if (mCallback != null) {
            if (mCallback.handleMessage(msg)) {
                return;
            }
        }
        handleMessage(msg);
    }
}
public interface Callback {
    public boolean handleMessage(Message msg);
}
private static void handleCallback(Message message) {
    message.callback.run();
}
public void handleMessage(Message msg) {
}

该方法的主要作用

对于 Message 来说,就是回调接口的作用:Message 对象持有Handler对象,通过调用这个 Handler 对象的 dispatchMessage方法,把 Message 对象回调给了 Handler

对于 Handler 来说,就是分发消息的作用:

  • 如果 Message 对象的 callback 字段不为空,那么这个消息内部持有了一个 Runnable 对象,就调用 handleCallback 方法来运行那个 Runnable 对象封装的代码;
  • 如果 Message 对象的 callback 字段为空,而 mCallback 对象不为 null,就使用 Callback 类的 handleMessage 方法来处理消息;这个 mCallback 是在 Handler 的构造方法里面完成赋值的,使用这个回调的好处是不必要为了重写 Handler 类的 handleMessage 方法而去子类化 Handler
  • 如果 Message 对象的 callback 字段为空,且mCallback 对象为 null,就使用 Handler 类的 handleMessage 方法来处理消息了。

从获取消息来看

Handler 封装了一系列的 obtainMessage 工具方法,方便我们拿到 Message 对象。

从移除消息来看

Handler 封装了 removeXXX 方法,内部委托给 MessageQueue 对象去做真正的工作。

public final void removeMessages(int what) {
    mQueue.removeMessages(this, what, null);
}

总结一下:使用 Handler 可以组合 MessageQueue 对象和 Looper 对象,可以发送消息,可以处理消息,可以获取消息对象,可以移除消息,所以说Handler 是 Android 消息机制的上层接口。

1.6 Android 消息机制的整体流程是什么?

在这里插入图片描述

图解:

  • 在主线程创建 Handler 对象 handler,默认使用的是主线程的 Looper 对象以及对应的 MessageQueue 对象;
  • 在工作线程通过 Handler 对象 handler 的发送消息方法发送消息,最终通过 MessageQueue 对象的 enqueueMessage 方法把消息加入到消息队列中;
  • Looper.loop() 方法运行在创建 Handler 里的线程,在这里就是运行在主线程, Loop.loop() 方法不断从消息队列中获取符合条件的 Message 对象;
  • 获取到符合条件的 Message 对象后,通过 Message 对象持有的 target 字段(实际就是发送该消息的那个 Handler 对象)的 dispatchMessage 方法把消息回调给发送消息的那个 Handler,这样消息就在主线程接收到了。

2. Android 的消息机制分析

2.1 ThreadLocal 的使用场景有哪些?

场景一:当某些数据是以线程为作用域并且不同线程具有不同的线程副本的时候,考虑使用 ThreadLocal

对于 Handler 来说,它需要获取当前线程的 LooperLooper 的作用域就是线程并且不同线程具有不同的 Looper),这时候使用 ThreadLocal 就可以轻松实现 Looper 在线程中的存取。

对于 SimpleDateFormat 来说,它不是线程安全的,也就是说在多线程并发操作时,会抛出异常。看演示代码如下:

public class SimpleDateFormatDemo1 {
    private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) {
        ExecutorService threadPool = new ThreadPoolExecutor(
                5, 50, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100));

        List<String> data = Arrays.asList(
                "2021-03-01 00:00:00",
                "2020-01-01 12:11:40",
                "2019-07-02 23:11:23",
                "2010-12-03 08:22:33",
                "2013-11-29 10:10:10",
                "2017-09-01 14:14:14",
                "2021-04-01 15:15:15"
        );

        for (String date : data) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        System.out.println(sdf.parse(date));
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }

        threadPool.shutdown();

    }
}

运行这段程序,会出现这样的异常:

java.lang.NumberFormatException: For input string: ".103E2103E2"
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
	at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.lang.Double.parseDouble(Double.java:538)
	at java.text.DigitList.getDouble(DigitList.java:169)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.java.advanced.features.concurrent.threadlocal.SimpleDateFormatDemo1$1.run(SimpleDateFormatDemo1.java:45)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

为了解决 SimpleDateFormat 线程不安全的问题,我们可以使用一个 synchronized修饰的方法封装其 parse 方法,但是这样多线程下会竞争锁,效率不高。使用 ThreadLocal 来为每个线程创建一个专属的 SimpleDateFormat 对象副本,当一个线程下需要获取 SimpleDateFormat 对象进行操作时,它获取的是它自己的那个副本,对其他线程的 SimpleDateFormat对象副本没有影响,这样就不会发生线程不安全的问题了。

public class SimpleDateFormatDemo2 {
    private static ThreadLocal<SimpleDateFormat> threadLocal = ThreadLocal.withInitial(new Supplier<SimpleDateFormat>() {
        @Override
        public SimpleDateFormat get() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    });

    public static void main(String[] args) {
        ExecutorService threadPool = new ThreadPoolExecutor(
                5, 50, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100));

        List<String> data = Arrays.asList(
                "2021-03-01 00:00:00",
                "2020-01-01 12:11:40",
                "2019-07-02 23:11:23",
                "2010-12-03 08:22:33",
                "2013-11-29 10:10:10",
                "2017-09-01 14:14:14",
                "2021-04-01 15:15:15"
        );

        for (String date : data) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        System.out.println(threadLocal.get().parse(date));
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        threadPool.shutdown();
    }
}

多次运行程序,都可以正常解析了。

实际上,JDK1.8 提供了线程安全的 DateTimeFormatter 来替代线程不安全的 SimpleDateFormat。这里就不详细说明了。

场景二:使用 ThreadLocal来进行复杂逻辑下的对象传递。

比如一个线程中的任务,它的函数调用栈比较深,或者说调用链有不能修改的第三方库,这时我们想要传递一个监听器参数进去,该怎么办呢?

如果都是自己的代码,可以修改给调用链上的每一个函数增加监听器参数,但是这样改动的地方很多,容易出错,也很麻烦;

如果调用链有不可以改动的第三方库,可以将监听器作为静态变量供线程访问,但是在多线程下每个线程都要有自己的监听器对象,我们就需要用一个集合(以线程名为键,以监听器为值)来管理这些监听器了。这样多线程下在获取指定的监听器的时候,还是会存在就集合的竞争。所以不好。

使用 ThreadLocal 就可以解决这个问题。代码如下:

private static ThreadLocal<Runnable> runnableThreadLocal = new ThreadLocal<>();
public void threadLocalargs(View view) {
   new Thread("thread1") {
       @Override
       public void run() {
           task();
       }
   }.start();
   new Thread("thread2") {
       @Override
       public void run() {
           task();
       }
   }.start();
}
private void task() {
    Runnable runnable = () -> Log.d(TAG, "run: " + Thread.currentThread().getName());
    runnableThreadLocal.set(runnable);
    method1();
}
private void method1() {
    method2();
}
private void method2() {
    method3();
}
private void method3() {
    runnableThreadLocal.get().run();
}

打印日志如下:

D/MainActivity: run: thread1
D/MainActivity: run: thread2

2.2 为什么 ThreadLocal 可以在多个线程中互不干扰地存储和修改数据?

先来看下 ThreadLocal 的基本使用:

private static ThreadLocal<Boolean> sBooleanThreadLocal = new ThreadLocal<>();
private static ThreadLocal<String> sStringThreadLocal = new ThreadLocal<>();
public void threadLocal_basic(View view) {
    sBooleanThreadLocal.set(true);
    log();
    new Thread("Thread#1") {
        @Override
        public void run() {
            super.run();
            sBooleanThreadLocal.set(false);
            sStringThreadLocal.set(Thread.currentThread().getName());
            log();
        }
    }.start();
    new Thread("Thread#2"){
        @Override
        public void run() {
            super.run();
            sStringThreadLocal.set(Thread.currentThread().getName());
            log();
        }
    }.start();
}
private void log() {
    Log.d(TAG, "["+ Thread.currentThread().getName() +"]"+ "sBooleanThreadLocal.get()=" + sBooleanThreadLocal.get());
    Log.d(TAG, "["+ Thread.currentThread().getName() +"]"+ "sStringThreadLocal.get()=" + sStringThreadLocal.get());
}

打印日志如下:

D/MainActivity: [main]sBooleanThreadLocal.get()=true
D/MainActivity: [main]sStringThreadLocal.get()=null
D/MainActivity: [Thread#1]sBooleanThreadLocal.get()=false
D/MainActivity: [Thread#1]sStringThreadLocal.get()=Thread#1
D/MainActivity: [Thread#2]sBooleanThreadLocal.get()=null
D/MainActivity: [Thread#2]sStringThreadLocal.get()=Thread#2

可以看到,虽然我们在不同的线程中访问的都是同样的 ThreadLocal 对象,但是它们从 ThreadLocal 对象中获取的值,正好就是设置的值或者是默认值。

这里我们看的是 JDK1.8 的源码。

我们先来看一下相关的类关系吧:

  • 每个Thread对象都持有一个ThreadLocal.ThreadLocalMap 对象threadLocals

  • ThreadLocalMapThreadLocal 的静态内部类,ThreadLocalMap里面维护了一个Entry数组table`;

  • EntryThreadLocal 的静态内部类,封装了一个键值对,key 是 ThreadLoal对象,value 是 ThreadLocal 的泛型对应的值。

绘制类关系图如下:

在这里插入图片描述

核心代码如下:

public class ThreadLocal<T> {
    static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        private Entry[] table;
    }
}
public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ...
}

接着我们去看 ThreadLocalget()set() 方法:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

该方法的主要作用:

  • 获取当前线程持有的 ThreadLocalMap 变量;
  • 以当前 ThreadLocal 对象为 key,以泛型对应的值为 value,存入到 ThreadLocalMap 中。
public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

该方法的主要作用:

  • 获取当前线程持有的 ThreadLocalMap 变量;
  • ThreadLocalMap 变量中,以 ThreadLocal 对象为 key,查找对应的 Entry 对象;
  • 获取 Entry 对象的 value 部分,并返回。

ThreadLocalsetget 方法可以看出,ThreadLocal 对象操作的 ThreadLocalMap 对象都是当前线程的 ThreadLocalMap对象;而每个 ThreadLocalMap 内部会持有一个 Entry 数组,所以 ThreadLocal 对象操作的 Entry数组都是当前线程的 Entry 数组,也就是说,读/写操作都是针对当前线程的 Entry 数组,不涉及其他线程,不会影响其他线程,所以 ThreadLocal 可以在多个线程中互不干扰地存储和修改数据。

为了更好地理解 ThreadLocal 的线程隔离,把演示 ThreadLocal 的小例子绘制成内存图,如下:
在这里插入图片描述

2.3 ThreadLocal 在消息机制中是如何使用的?

public final class Looper {

    // sThreadLocal.get() will return null unless you've called prepare().
    static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();
	
	private static void prepare(boolean quitAllowed) {
		if (sThreadLocal.get() != null) {
			throw new RuntimeException("Only one Looper may be created per thread");
		}
		sThreadLocal.set(new Looper(quitAllowed));
	}
	
	public static Looper myLooper() {
		return sThreadLocal.get();
	}
	
}

可以看到:

Looper 类中声明了一个ThreadLocal 变量 sThreadLocal,它被 static 关键字和 final 关键字修饰:被 static 修饰,则无论创建多少个 Looper 对象, sThreadLocal 变量都只占用一份存储区域;被 final 修饰,则变量 sThreadLocal 不可以指向另一个对象。你可能会想:线程可能有很多个,每个线程都可能需要一个 Looper 对象,所有的线程都使用一个 ThreadLocal 对象来管理,这够吗?够了。请看内存图:

在这里插入图片描述

prepare() 方法:首先尝试从 sThreadLocal获取对应线程的 Looper 对象,如果存在就抛出异常,这样就保证了一个线程只能对应一个 Looper 对象;如果不存在,则会创建一个 Looper 对象,并保存在 sThreadLocal 中,实际上是保存在了对应线程的 threadLocals 这个 ThreadLocalMap 里面。

myLooper() 方法:获取对应线程的 Looper 对象,已给外部(如 HandlerLooper等)使用。

2.4 消息队列是如何完成消息的插入和读取操作的?

消息队列就是 MessageQueue,它使用 boolean enqueueMessage(Message msg, long when) 方法完成消息的插入,使用 Message next() 完成消息的读取(在获取到时会先把该消息从消息队列中移除)。

MessageQueue 是通过一个基于时间排序的单链表数据结构来维护消息列表。

下面看一下它的两个主要方法吧。

public final class MessageQueue {
	// 链表的头节点
	Message mMessages;
	boolean enqueueMessage(Message msg, long when) {
		...
		synchronized (this) {
			...
			msg.markInUse();
			msg.when = when;
			Message p = mMessages;
			boolean needWake;
			if (p == null || when == 0 || when < p.when) {
				// 当队列中没有任何消息,或者新的 Message 的触发时间是最早的,则新的 Message 应该作为头节点
				msg.next = p;
				mMessages = msg;
				needWake = mBlocked;
			} else {
				// 新的 Message 应该插入到队列中
				needWake = mBlocked && p.target == null && msg.isAsynchronous();
				Message prev;
				// 遍历链表,找到应该插入的位置
				for (;;) {
					prev = p;
					p = p.next;
					if (p == null || when < p.when) {
						break;
					}
					if (needWake && p.isAsynchronous()) {
						needWake = false;
					}
				}
				// 插入新的 Message
				msg.next = p; // invariant: p == prev.next
				prev.next = msg;
			}
			if (needWake) {
				nativeWake(mPtr);
			}
		}
		return true;
	}	
}
public final class MessageQueue {
	// 链表的头节点
	Message mMessages;
	Message next() {
		final long ptr = mPtr;
		if (ptr == 0) {
			return null;
		}
		int pendingIdleHandlerCount = -1; // -1 only during first iteration
		int nextPollTimeoutMillis = 0;
		for (;;) {
			if (nextPollTimeoutMillis != 0) {
				Binder.flushPendingCommands();
			}
			nativePollOnce(ptr, nextPollTimeoutMillis);
			synchronized (this) {
				final long now = SystemClock.uptimeMillis();
				Message prevMsg = null;
				Message msg = mMessages;
				// 当有同步屏障消息时,会进入此分支
				if (msg != null && msg.target == null) {
					// 通过 do while 循环来获取下一个异步消息。
					// 如果没有获取到异步消息,不会结束 do while 循环。
					do {
						prevMsg = msg;
						msg = msg.next;
					} while (msg != null && !msg.isAsynchronous());
				}
				if (msg != null) {
					if (now < msg.when) {
						// 下一条消息的触发时间还没有到,则设置一个超时时间以在触发时间到达时唤醒队列
						nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
					} else {
						// 获取一条消息
						mBlocked = false;
						// 从链表中移除获取到的消息
						if (prevMsg != null) {
							prevMsg.next = msg.next;
						} else {
							mMessages = msg.next;
						}
						msg.next = null;
						// 返回获取的消息
						return msg;
					}
				} else {
					// No more messages.
					nextPollTimeoutMillis = -1;
				}
				if (mQuitting) {
					dispose();
					return null;
				}
				...// 省略了 IdleHandler 部分
			}
			...// 省略了 IdleHandler 部分
		}
	}
}

2.5 Looper 在消息机制中的作用是什么?

Looper 在 Android 的消息机制中扮演着消息循环的角色,它通过 loop() 方法不停地从 MessageQueue 中查看是否有获取到消息:如果获取到消息,就立即交给 Handler 处理,如果获取不到消息,就一直阻塞,如果获取到 null,就退出消息循环了。

2.6 Looper.prepare() 方法与 Looper.prepareMainLooper() 方法有什么区别?

  • prepare() 方法创建的 Looper 消息循环可以退出,prepareMainLooper() 方法创建的消息循环不可以退出。

    prepare() 方法内部调用 prepare(boolean quitAllowed)传参的参数是 true,而和prepareMainLooper() 内部调用 prepare(boolean quitAllowed)传参的参数是 false

    static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();
    private static void prepare(boolean quitAllowed) {
        if (sThreadLocal.get() != null) {
            throw new RuntimeException("Only one Looper may be created per thread");
        }
        sThreadLocal.set(new Looper(quitAllowed));
    }
    

    该方法的作用:

    • 检查从线程本地变量 sThreadLocal 中获取的 Looper 对象是否为 null,不为 null,则抛出异常:每个线程只能创建一个 Looper 对象;

    • 调用 Looper 的构造方法,创建 Looper 对象:构造方法里面创建了 MessageQueue 对象,并持有了创建 Looper 的当前线程

      private Looper(boolean quitAllowed) {
          mQueue = new MessageQueue(quitAllowed);
          mThread = Thread.currentThread();
      }
      

      quitAllowed 传入到消息队列中,如果这个值为 true,则表示允许退出消息循环;反之,不允许。主线程的消息循环不可以退出。

    • Looper 对象保存在线程本地变量 sThreadLocal 中。

  • prepare() 方法是用于子线程创建 Looper 对象的,prepareMainLooper() 是用于主线程创建 Looper 对象的。

2.7 Looper 的 quit() 方法和 quitSafely() 方法都用来退出消息循环,它们的区别是什么?

quit() 会设置一个退出标记,并直接退出消息循环;

quitSafely() 只是设置了一个退出标记,Looper 仍会处理完消息队列中的非延时消息后才会退出。

public final class Looper {
    final MessageQueue mQueue;
    public void quit() {
        mQueue.quit(false);
    }
    public void quitSafely() {
        mQueue.quit(true);
    }
}
public final class MessageQueue {
    void quit(boolean safe) {
        if (!mQuitAllowed) {
            throw new IllegalStateException("Main thread not allowed to quit.");
        }
        synchronized (this) {
            if (mQuitting) {
                return;
            }
            // 设置退出标记为 true
            mQuitting = true;
            if (safe) {
                // 移除所有需要延时处理的消息,保留需要触发的消息让消息队列继续处理
                removeAllFutureMessagesLocked();
            } else {
                // 移除所有的消息
                removeAllMessagesLocked();
            }
            // We can assume mPtr != 0 because mQuitting was previously false.
            nativeWake(mPtr);
        }
    }
}

不管是调用了 quit()还是 quitSafely() 方法,如果再向消息队列添加消息,都会返回 false

public final class MessageQueue {
    boolean enqueueMessage(Message msg, long when) {
        ...
        synchronized (this) {
            if (mQuitting) { // 退出标记为 true,进入分支,会返回 false。
                ...
                return false;
            }
            ...
        }
        return true;
    }
}

需要说明的是,在子线程中手动创建 Looper,并调用Looperloop() 方法,如果不主动调用 quit 方法来退出消息循环,那么这个子线程就一直处于等待的状态,具体来说就是消息循环等待下一个消息。所以,在不需要消息循环时,一定要主动终止 Looper

2.8 Looper.loop() 方法在消息队列中没有消息时就会退出,这种说法对吗?

这种说法不对。我们用反证法来说明,假设Looper.loop() 方法在消息队列中没有消息时就会退出的说法正确,那么现在我们在一个子线程里,这样写代码:

class LooperThread extends Thread {
    public Handler mHandler;

    public void run() {
        Looper.prepare();

        mHandler = new Handler() {
            public void handleMessage(Message msg) {
                // process incoming messages here
            }
        };

        Looper.loop();
    }
}

然后现在消息队列肯定没有消息,这样 Looper.loop() 方法就会退出了。

但是,我们在主线程使用 mHandler 发送消息,仍然可以发送成功,并且可以在子线程里面接收到。与假设矛盾,所以假设不成立,这种说法是错误的。

我们去看看 loop() 方法在那里 return 了:

public static void loop() {
    final Looper me = myLooper();
    
    final MessageQueue queue = me.mQueue;
    Binder.clearCallingIdentity();
    final long ident = Binder.clearCallingIdentity();
    for (;;) {
        Message msg = queue.next(); // might block
        if (msg == null) {
            // 如果获取到的 Message 对象为 null,就退出消息循环。
            return;
        } 
        msg.target.dispatchMessage(msg);
        final long newIdent = Binder.clearCallingIdentity();      
        msg.recycleUnchecked();
    }
}

那么,什么时候 MessageQueuenext 方法会返回 null 呢?

Looperquit 方法被调用时,消息队列被标记为退出状态,它的 next 方法就会返回 null

LooperquitSafely 方法被调用时,消息队列被标记为退出状态,等处理完非延时消息后,它的 next 方法才会返回 null

实际上,当消息队列中没有消息时,调用 next 方法时不会返回的,它会一直阻塞,这样 loop 方法也阻塞在那里。

2.9 为什么主线程不会因为 Looper.loop() 里的死循环卡死?

首先,需要说明的是 Looper.loop() 里的 for 循环并不是一个死循环,有些同学可能会说,是个死循环啊,我看过源码的。好吧,我们先看一下死循环的定义:死循环(endless loop)是指无法靠自身的控制终止的循环。再看一下,Looper.loop() 方法的源码:

public static void loop() {
    final Looper me = myLooper();
    final MessageQueue queue = me.mQueue;
    Binder.clearCallingIdentity();
    final long ident = Binder.clearCallingIdentity();
    for (;;) {
        Message msg = queue.next(); // might block
        // 当取到的消息为 null 时,进入 if 分支,结束掉循环。
        if (msg == null) {
            return;
        }
      	...// 省略与分析无关的代码
    }
}

可以看到,for(;;)是一个可以考自身的控制终止的循环,因此它不是一个死循环。

但是,在应用运行的时候,这个 for 循环是没有机会结束循环的,所以这里我们称之为无限循环。

其次,主线程需要这样一个无限循环。我们知道,线程里面是一段可执行的代码,当可执行代码执行完成后,线程生命周期就结束了,线程就退出了。但是,对于主线程,它负责控制UI界面的显示、更新和控件交互,所以它的生命周期必须与应用的生命周期一样长才可以。那么,怎么办呢?就需要主线程里的可执行代码执行不完,也就是说主线程里要一直有可执行代码。很容易想到,流程控制中的循环可以达到这一目的吧。Android 正是采用了无限循环的。

最后,说一下卡死。卡死是什么呢?卡死就是程序没有继续执行,而是停在了某个地方。Android 里的 Looper.loop() 方法是循环执行的,所以这里说的卡死应该理解为阻塞才对。阻塞确实是存在的,也是有意义的。这是因为当Android的线程 A 消息队列在空闲时,会调用 nativePollOnce(这是一个 native 方法,具体来说是通过 Linux 系统的 Epoll 机制中的 epoll_wait 函数进行的),使线程处于休眠状态,释放CPU资源;当在线程 B 里面向线程 A 的消息队列插入消息时,会调用 nativeWake,唤醒线程 A。

这里的阻塞不会导致应用界面ANR吗?不会的。ANR 是 application not responding,即程序未响应。现在这里发生阻塞是因为程序没有事件需要处理,也就谈不上响应了。

2.10 Handler 什么情况下会导致内存泄漏?为什么?怎么办?

参考:一次性讲清楚 Handler 可能导致的内存泄漏和解决办法

这篇文章比较详细地讲解了 Handler 内存泄漏的问题。

2.11 为什么 Looper.loop() 方法和 MessageQueue.next() 方法里都有一个 for(;😉 循环?

Looper.loop() 方法中的 for(;;) 循环的作用是形成线程里的无限循环,在循环里执行要处理的消息;MessageQueue.next() 方法中的 for(;;)循环的作用是为了可以在没有消息或者消息执行时刻未到达时执行 nativePollOnce 方法,使线程进入休眠状态,释放 CPU 资源。

2.12 消息的分类以及它们的作用分别是什么?

消息分为同步消息,异步消息和同步屏障消息三种。

消息类型isAsynchronoustarget
同步消息false不为 null
异步消息true不为 null
同步屏障消息false(这点只是默认值,不重要)为 null

直观地查看同步屏障的作用,可以查看笔者的Android筑基——可视化方式理解 Handler 的同步屏障机制

当消息队列中没有同步屏障消息时,同步消息和异步消息会同等处理,即按照时间优先级顺序处理;

当消息队列中有同步屏障消息时,会只处理异步消息,不会处理同步消息。

对应的代码如下:

Message next() {
	...
    for (;;) {
        if (nextPollTimeoutMillis != 0) {
            Binder.flushPendingCommands();
        }
        nativePollOnce(ptr, nextPollTimeoutMillis);
        synchronized (this) {
            final long now = SystemClock.uptimeMillis();
            Message prevMsg = null;
            Message msg = mMessages;
			// 判断是否有同步屏障消息
            if (msg != null && msg.target == null) {
				// 被同步屏障挡住了. 
				// 通过 do while 循环去查找到一个异步消息:
				// 如果查找到异步消息,就退出 do while 循环;
				// 如果查找不到异步消息,就一直处于 do while 循环中。
                do {
                    prevMsg = msg;
                    msg = msg.next;
                } while (msg != null && !msg.isAsynchronous());
            }
			// 下面是准备取出消息的代码
            if (msg != null) {
                if (now < msg.when) {
                    nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                } else {
                    mBlocked = false;
                    if (prevMsg != null) {
                        prevMsg.next = msg.next;
                    } else {
                        mMessages = msg.next;
                    }
                    msg.next = null;
                    return msg;
                }
            } else {
                nextPollTimeoutMillis = -1;
            }
            if (mQuitting) {
                dispose();
                return null;
            }
        }
        nextPollTimeoutMillis = 0;
    }
}

2.13 如何获取 Message? Message 使用完后如何处理?为什么这样设计?

通过 Message 的一系列 obtain 工具类方法来获取 Message 对象,内部都调用了:

private static final Object sPoolSync = new Object();
private static Message sPool;
private static int sPoolSize = 0;
private static final int MAX_POOL_SIZE = 50;
public static Message obtain() {
    synchronized (sPoolSync) {
        if (sPool != null) {
            Message m = sPool;
            sPool = m.next;
            m.next = null;
            m.flags = 0; // clear in-use flag
            sPoolSize--;
            return m;
        }
    }
    return new Message();
}

这个方法的作用是:获取 Message 对象的时候,尝试从 sPool 单链表里面取出一个 Message 实例,如果取到就直接返回;否则,只能创建一个新的 Message 对象返回。

需要注意的是,

  • sPool 单链表消息缓存池的大小是有上限的,即 MAX_POOL_SIZE = 50;
  • sPool 是静态的,这就说明在整个进程中只有一个 sPool,整个进程共用一个消息缓存池。

Message 使用完毕后,会尝试将它放回消息缓存池里面,具体看下面的 recycleUnchecked 方法:

void recycleUnchecked() {
    flags = FLAG_IN_USE;
    what = 0;
    arg1 = 0;
    arg2 = 0;
    obj = null;
    replyTo = null;
    sendingUid = -1;
    when = 0;
    target = null;
    callback = null;
    data = null;
    synchronized (sPoolSync) {
        if (sPoolSize < MAX_POOL_SIZE) {
            next = sPool;
            sPool = this;
            sPoolSize++;
        }
    }
}

LooperMessageQueue 中会调用此方法来回收消息。

这个方法的主要作用:先清除 Message 对象的字段值,再尝试把它放到消息缓存池里面。

为什么要这么设计呢?

这是因为在 Android 中,使用 Message 的场景非常多,如果都是通过 new 的方式来创建会带来性能消耗;而采用消息缓存池,就可以复用 Message 实例,一定程度上减少 new 的方式来创建,更加高效。这其实就是享元设计模式的应用了。

3. 主线程的消息循环

3.1 主线程的消息循环模型是如何建立的?

构建主线程消息循环,需要调用 Looper.prepareMainLooper() 方法,来创建 MessageQueue 对象和 Looper 对象。我们查看调用这个方法的地方有哪些:

在这里插入图片描述

可以看到在三个地方调用了这个方法:ActivityThread.javaBridge.javaSystemServer.java

其中 ActivityThreadmain 函数里开启了 App 进程的主线程消息循环,SystemServerrun 方法开启了 system_server 进程的主线程消息循环。

这里以 App 进程的主线程消息循环来做说明:

ActivityThread 类是 App 进程的初始类,它的 main 函数是 App 进程的入口。Looper.prepareMainLooper() 方法就是在它的 main 函数中调用的。

我们以 scheduleLaunchActivity 为例,来说明主线程的消息循环,关键代码如下:

public final class ActivityThread {
	final ApplicationThread mAppThread = new ApplicationThread();
	final H mH = new H();
	
	private class ApplicationThread extends ApplicationThreadNative {
		
		public final void scheduleLaunchActivity(Intent intent, IBinder token, int ident,
				ActivityInfo info, Configuration curConfig, CompatibilityInfo compatInfo,
				IVoiceInteractor voiceInteractor, int procState, Bundle state,
				PersistableBundle persistentState, List<ResultInfo> pendingResults,
				List<Intent> pendingNewIntents, boolean notResumed, boolean isForward,
				ProfilerInfo profilerInfo) {
			updateProcessState(procState, false);
			ActivityClientRecord r = new ActivityClientRecord();
			... // 省略一系列赋值操作
			sendMessage(H.LAUNCH_ACTIVITY, r);
		}
		...// 省略了其他的跨进程调用方法,只保留 scheduleLaunchActivity 方法
	}	
	
	private class H extends Handler {
		public static final int LAUNCH_ACTIVITY         = 100;
		... // 省略了其他的常量定义,只保留 LAUNCH_ACTIVITY
	   
		public void handleMessage(Message msg) {
			switch (msg.what) {
				case LAUNCH_ACTIVITY: {
					Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
					final ActivityClientRecord r = (ActivityClientRecord) msg.obj;
					r.packageInfo = getPackageInfoNoCheck(
							r.activityInfo.applicationInfo, r.compatInfo);
					handleLaunchActivity(r, null);
					Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
				} break;
				... // 省略了其他的 case 分支,只保留 LAUNCH_ACTIVITY 分支
			}
		}
		
	}	
	
	private void sendMessage(int what, Object obj) {
		sendMessage(what, obj, 0, 0, false);
	}

	private void sendMessage(int what, Object obj, int arg1, int arg2, boolean async) {
		Message msg = Message.obtain();
		msg.what = what;
		msg.obj = obj;
		msg.arg1 = arg1;
		msg.arg2 = arg2;
		if (async) {
			msg.setAsynchronous(true);
		}
		mH.sendMessage(msg);
	}
	
	private void attach(boolean system) {
		if (!system) {
			final IActivityManager mgr = ActivityManagerNative.getDefault();
				mgr.attachApplication(mAppThread);     
		} else {
			...
		}
	}

	public static void main(String[] args) {
		...
		Looper.prepareMainLooper();
		ActivityThread thread = new ActivityThread();
		thread.attach(false);
		if (sMainThreadHandler == null) {
			sMainThreadHandler = thread.getHandler();
		}
		AsyncTask.init();
		
		Looper.loop();
		throw new RuntimeException("Main thread loop unexpectedly exited");
	}
}

下面对关键代码进行说明:

  1. main 方法是 App 进程的入口,在这里面:

    • 通过 Looper.prepareMainLooper() 创建了主线程的 Looper 以及 MessageQueue;

    • 实例化了 ActivityThread 对象,这样也就在主线程初始化了 ActivityThread对象的成员变量 mAppThreadmH

      final ApplicationThread mAppThread = new ApplicationThread();
      final H mH = new H();
      

      ApplicationThreadH 都是 ActivityThread 的内部类,其中 ApplicationThread是作为 IApplicationThread binder 接口的服务端对象存在的,IApplicationThread 是 system_server 进程向 App 进程发起通信的桥梁;H 是一个 Handler 类的子类。

    • 调用 ActivityThread 对象的 attach(false) 方法,内部把 mAppThread 设置给了 AMS,相当于 App 进程设置了一个回调接口对象给 AMS,这样 AMS 需要向 App 进程发起通信时就可以通过 mAppThread 了。

    • 调用 Looper.loop() 方法,开启主线程的消息循环。

  2. 当 AMS 需要向客户端进程发起 scheduleLaunchActivity 请求时,就会通过它持有的 IApplicationThread 客户端对象来发起这个请求,经过 binder 驱动,会调用到 ApplicationThreadscheduleLaunchActivity 方法中,需要特别说明的是,因为是跨进程通信,此时ApplicationThreadscheduleLaunchActivity 方法是运行在客户端进程的 binder 线程池中。

  3. 接着调用 ActivityThreadsendMessage 方法,将需要发送的数据封装成一个 Message 对象,接着调用了 mHsendMessage 方法。

  4. H 类的 hanldeMessage 方法中接收到消息,这样就完成了将数据从binder线程池切换到了主线程。

参考

  1. Android中子线程真的不能更新UI吗?
    说明了子线程不能操作主线程 UI 的原因,以及演示子线程可以操作主线程 UI 的例子,主要就是有没有走 checkThread 方法。

  2. Android子线程真的不能更新UI么
    说明了在子线程里面更新 UI 是可以的,只要 View 视图体系是它自己创建的。

  3. Java中ThreadLocal的实际用途是啥?-敖丙

  4. ThreadLocal使用与原理-敖丙

  5. 面试官:“看你简历上写熟悉 Handler 机制,那聊聊 IdleHandler 吧?”

  6. 每日一问 听说过Handler中的IdleHandler吗?

  7. 临时抱佛脚:IdleHandler 的原理分析和妙用

  8. Android 避坑指南:实际经历来说说IdleHandler的坑

  9. 你知道android的MessageQueue.IdleHandler吗?

  10. Android中为什么主线程不会因为Looper.loop()里的死循环卡死?

    这是知乎上对于这个问题的讨论。

  11. 都 2021 年了,还有人在研究 Handler?

    这里面回答了20个关于 Handler 的问题,真的很好。

  12. 一次性讲清楚 Handler 可能导致的内存泄漏和解决办法

    这篇文章比较详细地讲解了 Handler 内存泄漏的问题。

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

willwaywang6

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值