news 2026/9/13 3:56:56

深入解析Android Looper:从消息循环到主线程机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析Android Looper:从消息循环到主线程机制

"Can't create handler inside thread that has not called Looper.prepare()",这行红色日志,几乎每个写过 Android 的开发者都见过。第一次遇到它时,我以为只是自己 new Handler 的姿势不对,后来把 Looper 源码翻了一遍才明白,这背后藏着一整套贯穿线程调用栈的消息驱动模型。

很多人用 Handler 发消息、更新 UI 都挺熟练,但一旦被问到 Looper 是什么、prepare() 做了什么、loop() 为什么是个死循环,就卡壳了。这篇文章我就围绕 Looper 这个核心,把它的初始化、循环机制、消息分发、主线程特殊性和实战踩坑一次性讲透。既适合刚接触 Handler 机制的新人建立整体认知,也适合准备面试、想深入源码细节的开发者查漏补缺。

1. prepare() 与 ThreadLocal:Looper 如何把自己"钉"在线程上

1.1 从那个"天天见"的崩溃异常说起

先还原一下典型的崩溃现场:在子线程里执行了类似这样的代码——新建一个 Handler 并发送消息,然后 App 直接抛异常退出。

Thread { // 在子线程中直接创建 Handler Handler handler = new Handler() handler.post { // 期望回到这个子线程执行任务 } }

崩溃信息指向Handler()的构造函数内部,最终落到Looper.myLooper()返回了 null,于是抛出RuntimeException: Can't create handler inside thread that has not called Looper.prepare()

这个异常的字面意思非常直白:你现在所在的线程没有调用过 Looper.prepare(),也就是说这个线程还没有一个可用的 Looper,所以 Handler 不知道把消息往哪送。换到主线程就不会有问题,因为系统在 App 启动阶段已经帮主线程准备好了 Looper。

1.2 ThreadLocal:每个线程的"专属变量阁楼"

要理解 Looper 为什么和线程强绑定,得先看 ThreadLocal 这个数据结构。Java 里每个 Thread 对象内部都维护了一个 ThreadLocalMap,可以把它想象成每个线程专属的储物柜:线程 A 存进去的数据,线程 B 去取,只能拿到 null,因为它们的储物柜是分开的。

Looper 的绑定正是借助 ThreadLocal 实现的。源码里这句:

static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();

prepare()做的事情,本质上就是把当前线程和 Looper 实例这个映射关系写进当前线程自己的 ThreadLocalMap 里。ThrealLocal 的命名也很直白:线程本地变量。Looper 选择存在这里,保证了一个线程只能有一个 Looper,而且获取时不需要加锁——因为每个线程只操作自己的变量阁楼,天然线程安全。

1.3 prepare() 源码级别的绑定逻辑

Looper.prepare()的实现比想象中简单:

public static void prepare() { prepare(true); } 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)); }

这里有个细节值得注意:prepare()会先检查当前线程是否已经存在 Looper,如果存在直接抛异常。这跟前面崩溃异常相互呼应——官方规定一个线程只能有一个 Looper,想重复准备就直接报错。

再看内部构造函数:

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

Looper 构造时干了两件大事:创建一个 MessageQueue(消息队列),记录当前线程对象。所以 Looper 和 MessageQueue 是一一对应的关系,且都保存在当前线程的 ThreadLocalMap 中。

1.4 为什么一个线程只能有一个 Looper

这个问题面试常常问,也常有人答不对。核心原因在于:一个 Looper 内部持有一个 MessageQueue,消息队列维护的是一个按执行时间排列的消息链表。如果同一个线程上存在多个 Looper,每个 Looper 各管一个队列,那这个消息的分发顺序立刻乱套:两个循环都从各自队列里取消息,线程无法决定先处理谁的,就谈不上顺序性和一致性了。

从设计角度也讲得通,ThreadLocal 本身天然只允许一个 Looper 实例出现。这让我想起一个比喻:一条单行道只能有一个交警在指挥交通,如果站了两个交警各自发号施令,车辆反而全部堵死。Looper 就是这条线程消息通道的"交警"。

回到实战中,如果你真需要在子线程里跑一个"消息循环",正确做法是先调用Looper.prepare(),再new Handler(),最后Looper.loop()。这个流程会在后面的实战章节展开。

2. loop() 死循环背后的真相:为什么主线程"死循环"却不卡死

2.1 死循环的入口:Looper.loop() 源码拆解

看完 prepare,很多人会继续追问:prepare 只做了绑定,那 Looper 怎么让线程持续处理消息?答案是Looper.loop()

public static void loop() { final Looper me = myLooper(); if (me == null) { throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread."); } final MessageQueue queue = me.mQueue; for (;;) { Message msg = queue.next(); // 关键阻塞点 if (msg == null) { return; } msg.target.dispatchMessage(msg); // 回收消息到消息池 msg.recycleUnchecked(); } }

这里最显眼的莫过于for (;;)这个死循环。很多人第一次看到会下意识觉得"这不会把线程卡死吗?"——尤其是主线程,它跑的就是这个死循环。要回答这个疑问,得把视线移到queue.next()上。

2.2 真正阻塞的不是循环,而是 MessageQueue.next()

如果只是读外层源码,很容易误以为 loop() 在用 CPU 空转等消息。实际上,阻塞发生在MessageQueue.next()内部。当消息队列里没有需要立即执行的消息时,next() 会让线程进入休眠状态,等有新消息入队时再唤醒它。

这个机制依赖 Linux 的 epoll。简单类比:你坐在工位上等快递,如果每隔几秒就抬头看门口有没有快递员(轮询),又累又费电;更好的做法是躺在床上睡一觉,等快递员按门铃再起来(epoll 阻塞)。Android 的 native 层就是通过epoll_wait挂起线程,等待文件描述符的可读事件来唤醒。

所以完整的循环节奏是:next() 无消息时阻塞休眠,一旦有消息入队就唤醒线程;dispatchMessage 处理完消息后回到 for 循环,继续 next() 等下一个消息。CPU 利用率非常低,主线程不会被"死循环"拖垮。

2.3 ANR 的锅不该由 loop() 来背

这里还要破除一个常见误区:主线程 ANR 不是因为有死循环,而是因为某个任务卡住了死循环的"某一步"。

系统判定 ANR 的维度主要是三大类:输入事件(InputDispatching)超时、广播(Broadcast)超时、服务(Service)执行超时。可以这样理解:loop() 是一个快递分拣流水线,每个包裹(消息)都需要被及时拆包处理。如果某个包裹特别难拆(比如在主线程做网络请求、磁盘大文件读写、数据库操作),后续所有包裹都会堵在传送带上。用户点屏幕产生的 Input 事件也排在后面,系统迟迟等不到它被分发出去,就判定 ANR。

换句话说,loop() 本身永远是活跃的,真正让界面卡死的,是某个耗时消息霸占了 dispatch 时沿线的时间片。这也是为什么官方反复强调:不要把耗时操作放在主线程。理解了 loop() 的结构,这句话背后的原理就清楚了。

2.4 quit() 与 quitSafely():如何安然退出循环

主线程的 Looper 不允许退出(prepare(false)),但自己创建的 Looper 通常需要能停止循环。Looper 提供两个方法:

  • quit():直接终止消息队列,清除所有尚未处理的消息。
  • quitSafely():标记退出,但会等队列中已有的延时消息到期并处理完,再退出循环。

这两个方法的机制差异值得留意。quit()走的是MessageQueue.quit(false),底层会把还是待处理状态的消息全部回收掉;quitSafely()走的是MessageQueue.quit(true),只移除还没有到执行时间的消息,已到期和即将到期的消息先执行完再退出。

实际开发中,我一般倾向用quitSafely(),尤其是消息队列里还有状态同步、UI 回调这类任务时,野蛮 quit 容易造成后续逻辑拿不到预期结果。不过也有例外,如果线程只负责心跳上报、轮询清理这类临时任务,直接 quit() 反而干净利落。

3. 消息从 sendMessage 到 handleMessage 的完整旅程

3.1 dispatchMessage 的三种分发分支

Handler 把消息 send 出去后,最终通过msg.target.dispatchMessage(msg)回到 Handler。target 就是发送这条消息的 Handler 对象。dispatchMessage 的分发策略非常清晰,优先级从高到低:

public void dispatchMessage(Message msg) { if (msg.callback != null) { handleCallback(msg); // 第一优先:Runnable 回调 } else { if (mCallback != null) { if (mCallback.handleMessage(msg)) { return; // 第二优先:Handler.Callback 接口 } } handleMessage(msg); // 第三优先:重写 handleMessage() } }

这三条分支对应三种常见的消息处理方式:

  • post(Runnable) 会把 Runnable 包装成 Message 的 callback 字段,分发时直接运行 Runnable。
  • 构造 Handler 时传 Callback 接口,可以在不继承 Handler 的情况下拦截消息,返回 true 表示已处理。
  • 最常见的继承 Handler 并重写 handleMessage(Message)。

理解这条优先级很重要。我曾经处理过一个 Bug:某个页面同时用了handler.post(Runnable)和继承 Handler 的 handleMessage,结果 Runnable 里的日志没有先走 handleMessage,导致数据状态对不上。后来才发现,post 包裹的 Runnable 本来就不经过 handleMessage,这条分发链路的优先级设计是刻意为之的。

3.2 消息池:obtain() 背后的对象复用设计

再看 loop() 里最后一个关键操作:msg.recycleUnchecked()。每条消息处理完后会被放回一个全局的消息池,等待下次被复用。

Android 的消息池最大容量是 50,通过Message.obtain()从池里取消息:

public static Message obtain() { synchronized (sPoolSync) { if (sPool != null) { Message m = sPool; sPool = m.next; m.next = null; m.flags = 0; sPoolSize--; return m; } } return new Message(); }

这层设计跟 JVM 对象回收的开销有关。Handler 消息流转极其频繁,如果每发一条消息都 new 一个 Message 对象,短时间会产生大量对象,触发 GC 的频次也会上升。消息池本质上是对象池模式,复用实例来降低内存分配和 GC 压力。

实战建议是:自己构造 Message 时优先用Message.obtain(),或者handler.obtainMessage(),不要直接 new Message。尤其是循环发消息、高频刷新的场景,效果立竿见影。这里有一个常见的错误:有人会在发送后把 Message 对象再拿来做别的用途,这不可取,因为消息一旦发送出去,内部状态由消息队列管理,外部不能再随意持有改写。

3.3 同步屏障与异步消息:机制里最容易被忽略的一层

聊到 MessageQueue 的队列结构,很多人只记得"按时间排序的链表"。队列里其实还特殊处理了一类"屏障消息"(SyncBarrier)。这个消息的 target 为 null,不会直接分发给任何 Handler,它的作用是挡住所有同步消息,让扫描逻辑优先去查找异步消息。

在 UI 渲染链路里,这个机制扮演了重要角色。系统在需要立即执行 UI 绘制或输入处理时,会往主线程消息队列插入一个同步屏障,然后发送异步消息,保证这些高优先级任务不会被普通同步消息阻塞。等关键帧处理完,再移除屏障。

普通排障时一般碰不到屏障消息,但面试中却常被问到:为什么 postSyncBarrier 不用 Handler?有没有见过 target 为 null 的消息?这类问题的落点就是同步屏障这个"隐藏角色"。也正因如此,系统在处理 View 绘制、Choreographer 帧回调时,能稳定抢占主线程的执行时机。

3.4 从 Handler 到 Looper:消息的顺序性与优先级

把上面内容串起来看,一条消息从 handler.sendMessage() 到 handleMessage 大概经历这样几个阶段:

  1. Message.obtain() 取出或创建消息实例,填充 what、obj、arg1/arg2 等字段。
  2. handler.enqueueMessage() 调用 MessageQueue.enqueueMessage() 插入消息链表。
  3. enqueueMessage() 按when(执行时间)找到合适的插入点,保证队列按时间递增排列。
  4. Looper.loop() 通过 queue.next() 取出当前最早需要执行的消息。
  5. 取出后通过 msg.target.dispatchMessage(msg) 走分发链路。
  6. handleMessage 或 Runnable.run() 处理完后,消息被回收进消息池。

这个链路也解释了为什么同一线程里发出去的消息整体是按序执行的:队列按时间排序,next() 每次取的都是当前时刻最早该处理的那一条。如果两条消息的执行时间相同,则按入队先后顺序排列。Async 异步消息、Sync 同步消息只是针对屏障的筛选维度,并不会破坏队列的时间顺序。

4. 主线程 Looper 与 Android 系统的协同

4.1 ActivityThread.main() 里的主 Looper 初始化

主线程之所以能直接用 Handler,是因为系统在应用进程启动时完成了初始化。看ActivityThread.main()

public static void main(String[] args) { Looper.prepareMainLooper(); // ... 创建 Application、启动主线程 Handler 等 Looper.loop(); }

prepareMainLooper()内部调用prepare(false),也就是"不允许退出"的 Looper。主线程消息循环必须一直在,一旦 quit 就等于应用退出,这就是主 Looper 和普通 Looper 最大的区别。

这里还有一个值得留意的方法:Looper.getMainLooper()。它返回主线程的 Looper 实例,在很多场景都有用——比如你用new Handler(Looper.getMainLooper())就能在任意线程创建 Handler,把任务切回主线程执行。

Handler mainHandler = new Handler(Looper.getMainLooper()); mainHandler.post(() -> { // 切回主线程更新 UI });

这种写法比记录一个全局 static Handler 更规范,也更容易控制生命周期。

4.2 H handler:系统消息的首席大管家

ActivityThread 内部有一个静态类 H 继承 Handler,主线程里几乎所有的系统级消息都通过它来分发。比如 Activity 生命周期切换、Service 启动停止、Broadcast 分发、ContentProvider 相关操作等。

H 的消息 what 值非常多,从 1 到一百多,包括:

  • H.LAUNCH_ACTIVITY
  • H.PAUSE_ACTIVITY
  • H.RESUME_ACTIVITY
  • H.STOP_ACTIVITY
  • H.SERVICE_ARGS
  • H.RECEIVER

系统把生命周期事件封装成消息塞进主线程队列,由 H 在 loop() 中逐个处理,从而驱动所有应用组件的运行。这跟普通开发者 Handler 的关系是一致的:谁往主线程塞消息,最终都由这个主 Loop 统一调度。H 只是其中影响力最大的一个 Handler。

理解这层后,很多"为什么某个生命周期回调比某个 post 晚执行"的问题就容易解释了:生命周期相关的消息本身也有先来后到的入队顺序,你 post 到队列里的任务如果排在某个生命周期消息后面,那么自然等这个生命周期回调执行完才会轮到你。

4.3 Looper 与 ANR、Input 响应、VSYNC 的关系

主线程 Looper 不仅处理应用自身的逻辑消息,还深度参与了 UI 渲染和输入响应。它们的交集在 MessageQueue 的这个特点上:所有任务都在同一条消息循环上排队,一旦循环被前一个任务堵住,后续所有类型的任务都会延迟。

  • Input 事件:用户点击屏幕后,InputDispatcher 通过 ViewRootImpl 把输入事件转化为消息,投递到主线程队列。如果队列前面有一个执行很慢的消息,点击事件就无法及时被处理,最终触发 InputDispatching 超时,表现为 ANR。
  • VSYNC:Choreographer 将垂直同步信号转为帧回调(FrameCallback),每一帧的测量、布局、绘制之间全部依赖主线程消息循环驱动。如果某个消息卡住超过两帧,就会掉帧,界面卡顿。
  • ANR 检测:系统会为主线程设置一系列超时监视器,它们并不直接监控 loop() 本身,而是通过检测"目标消息是否在限定时间内被相应处理完"来判断是否卡死。

这也是为什么启动优化、列表流畅度优化、ANR 排查,最终都会回到同一个核心:主线程消息队列到底被谁堵住了。工具上可以用Looper.getMainLooper().getQueue()结合自定义的 Printer 去 dump 主线程执行日志,定位耗时消息。

Looper.getMainLooper().setMessageLogging(new Printer() { @Override public void println(String x) { Log.d("MainLooper", x); } });

开启消息日志后,能在 logcat 里看到每条消息的分发时间点,配合 Systrace 可以进一步分析耗时位置。

4.4 isMainLooper 与 Looper.getMainLooper 的常见用法

代码基建里,判断当前是否主线程是很常见的需求:

public static boolean isMainThread() { return Looper.myLooper() == Looper.getMainLooper(); }

这个判断在封装网络层回调、图片加载库、数据库操作时常常用到:如果现在不是主线程,就通过主线程 Handler 去通知 UI 更新,避免直接操作 View 导致崩溃。

Looper.getMainLooper()也在线程切换时很常用。比如某个任务执行完后需要回到主线程,但不希望直接定位到某个 Activity 的 Handler,而是统一走主线程 Looper:

Handler mainHandler = new Handler(Looper.getMainLooper()); mainHandler.post(updateTask);

这种做法的好处是生命周期解耦,不持有页面引用,尤其适合基础库、工具类中的线程切换逻辑。

5. 实战踩坑与排查经验

5.1 子线程直接 new Handler 崩溃的应对

回到文章开头那个异常。如果你确实需要在子线程维护一个自己的消息循环,标准解法是:

  1. 调用Looper.prepare()完成线程与 Looper 的绑定。
  2. 在 prepare 之后创建 Handler。
  3. 调用Looper.loop()开启消息循环。
  4. 需要结束循环时,调用looper.quitSafely()

直接手写这套逻辑有点繁琐,Android 官方提供了封装好的 HandlerThread:

HandlerThread thread = new HandlerThread("work_thread"); thread.start(); Handler workHandler = new Handler(thread.getLooper()) { @Override public void handleMessage(Message msg) { // 在子线程处理消息 } }; // 发消息、延时任务都可以走 workHandler

HandlerThread 在 start() 后会自动执行 Looper.prepare() 和 Looper.loop(),外部只需要拿 getLooper() 创建 Handler 即可。它适合串行处理任务队列的场景,比如网络请求顺序执行、本地文件操作队列、日志异步写入等。

如果你遇到的是"子线程直接 new Handler 崩溃"的报错,先确认这个线程到底是普通 Thread,还是 HandlerThread,或者是 AsyncTask 的序列化线程池。防止自己重复踩。

5.2 HandlerThread 适用的开发场景

HandlerThread 最常见的用途是让耗时任务按队列顺序执行,同时还能通过 Handler 定时、延时触发。这里我列几个实际项目中我常用到的场景:

  • 日志上报:业务日志先写入队列,子线程逐个刷盘或上报,避免阻塞主线程,也避免并发写文件的竞争。
  • 轮询任务:比如蓝牙扫描、传感器数据采集,用 sendMessageDelayed 实现每个周期发一条消息给自己,保证下次采集按时执行。
  • 数据库批量操作:多条数据更新塞进同一个线程队列,借用消息队列天然的顺序优势,减少多线程事务冲突。
  • 图片转存、文件拷贝:耗时 IO 操作从主线程剥离,处理完通过主线程 Handler 回调 UI。

使用 HandlerThread 有几个细节:任务队列会无限积压的话要设置超时清空;页面销毁时,如果不是进程级长生命周期组件,记得在 onDestroy 里调用 quitSafely(),否则线程泄漏。

5.3 内存泄漏:Looper 生命周期与 Handler 匿名内部类

Handler 内存泄漏是个老生常谈,但很多人并不理解根因链条。核心链条是这样的:

主线程 Looper -> MessageQueue -> 某条 Message(持 target) -> Handler 实例 -> 外部类实例(Activity/Fragment)

因为主线程 Looper 的生命周期跟随进程,它内部的消息队列也常驻内存。如果你在 Activity 里用非静态内部类创建 Handler,并且发送了一条延迟消息(比如 sendMessageDelayed 延迟 5 分钟),这条消息在等待执行期间会一直持有 Activity 引用,Activity 无法被 GC 回收,页面资源也不会释放。

正确的修复姿势是:

  1. 把 Handler 定义成静态内部类。
  2. 对 Activity 使用 WeakReference 弱引用。
  3. onDestroy 或 onStop 时,调用handler.removeCallbacksAndMessages(null)清除所有消息。
private static class SafeHandler extends Handler { private final WeakReference<MainActivity> activityRef; SafeHandler(MainActivity activity) { activityRef = new WeakReference<>(activity); } @Override public void handleMessage(Message msg) { MainActivity activity = activityRef.get(); if (activity == null || activity.isFinishing()) { return; } // 更新 UI } }

这里的关键点不是"用了弱引用就万事大吉",而是removeCallbacksAndMessages(null)能及时把这条延迟消息从队列里摘掉,从源头切断持有链。两者的目的稍有不同:弱引用是防止处理消息时强持有 Activity,而 remove 是让消息队列不再等待这条消息。

5.4 IdleHandler 与启动优化:零成本利用循环空闲期

MessageQueue 里还有一个低调但实用的机制:IdleHandler。当 looper 暂时没有需要立即处理的消息,进入阻塞之前,会回调一下 IdleHandler 列表。如果返回 false,这个 IdleHandler 执行一次就会被移除;返回 true 则保留,下次空闲继续执行。

这个特性经常用于启动优化和延迟初始化。比如冷启动时,首页首帧的绘制消息往往排在关键位置,如果此时直接加载大图、初始化大量 SDK,会拖慢首帧时间。把这些任务交给 IdleHandler,系统处理完紧急消息后才会执行它们,对首帧的影响能降到最低。

Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() { @Override public boolean queueIdle() { // 在这里做延迟初始化 initImageLoader(); initPushSdk(); return false; } });

不过要提醒一个坑:如果主线程一直不空闲(比如动画一直在跑),IdleHandler 的执行时间是不确定的,不适合用于"必须尽快执行"的任务。同时需要留意它本身的耗时,如果 idle 任务的实现也很重,反而会拖慢下一次消息处理。实践时一般把任务拆小,优先做内存需要预热的轻量级初始化。

5.5 关于 Looper 你还可以继续深挖的方向

聊到这里,Looper 的核心脉络已经基本清晰。如果还想继续往下钻,有两条路线可以选:

  • 往底层看:MessageQueue.next() 里的 nativePollOnce 到底如何与 epoll 交互,native 层的 Looper 和 Java 层 Looper 怎么对应,可以研究 AOSP 中android_os_MessageQueue.cppLooper.cpp的实现。
  • 往上应用层看:Kotlin 协程的 Dispatchers.Main 其实就是基于 Handler 和 Looper 实现的,深入理解 Looper 后,再去读协程的 HandlerDispatcher 源码会轻松很多。

这两条路线能帮助你建立起从"消息循环"到"线程调度"的完整知识树,这也是很多大厂面试官层层追问的路径:Handler 是什么 -> Looper 是什么 -> 为什么不死循环 -> epoll 怎么实现 -> 协程怎么复用这套机制。

我个人这些年下来最大的体会是:Handler 消息机制不是背八股,它是一个非常好的线程调度入门样本。把 Looper 和 MessageQueue 的关系弄清楚,再去看 RxJava 的调度器、协程的调度器,都会有种"似曾相识"的感觉。所以在排查线程问题时,我习惯先画清楚"消息从哪里来、排在哪条队、由哪个 Looper 消费"这三件事,问题基本就解决一半了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 3:55:22

零刻ME Pro搭配飞牛fnOS:低成本打造家庭NAS全流程实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 3:53:34

MIKE21水环境模拟软件的核心技术与工程应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华