news 2026/9/9 10:51:40

Android ANR全面解析:从系统判定机制到线上治理实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android ANR全面解析:从系统判定机制到线上治理实战

1. 从一次深夜线上事故说起:ANR不是玄学,是可以被系统性击败的

如果你已经写了几年Android,大概率遇到过这样的场景:凌晨的报警群突然开始刷屏,用户反馈应用"打开就卡死""点哪儿都没反应",紧跟着就是系统弹窗"XXX 已停止运行"或"XXX 无响应"。你还没来得及打开电脑,产品经理已经在群里圈了你三次。

我印象最深的一次,是某次版本发布后ANR率直接翻了5倍,集中在某款低端机上。当时第一反应是"是不是系统版本兼容问题",翻了一圈代码才发现,一个刚上线的组件把一次网络请求放到了主线程,而且加了超时重试,导致用户在弱网环境下体验到了十几秒的界面冻结。那次之后我把ANR从"偶发故障"的类别里拎了出来,认真研究它的产生机制、卡顿现场还原和线上监控方案。

这篇内容就是基于那段时间及后续项目中的实践经验做的系统梳理。ANR的全称是Application Not Responding,当主线程在限定时间内没有完成输入事件处理、广播处理或服务执行的关键操作时,系统就会判定应用无响应。它本质上是系统在替你给用户一个交代:你的应用已经hold不住了。

无论你是刚入门Android、正在做性能优化,还是负责稳定性治理的技术负责人,这篇文章都会给你一套从原因分析、现场排查到修复防复发的完整打法。整篇没有玄学,全部是能落地到代码和日志里的硬功夫。

2. ANR的系统判定机制:先搞清楚系统是怎么"生气"的

2.1 为什么是"5秒"和"10秒",这些时间窗口到底是谁定的

要理解ANR,首先要理解它的判定机制。Android系统对不同类型的组件有着不同的超时阈值,这些阈值不是随随便便定的,而是基于一个核心假设:主线程应该在极短时间内完成一次事件响应。

根据安卓系统中ActivityManagerService和InputDispatcher的实现逻辑,当前主流的判定阈值大概是这样的:

场景超时阈值触发条件
输入事件(Input Dispatching)5秒按键、触摸事件等分发给应用后,5秒内没有消费完
广播BroadcastReceiver(前台)10秒onReceive执行超时
广播BroadcastReceiver(后台)60秒onReceive执行超时
服务Service(前台)20秒onCreate/onStartCommand等关键生命周期执行超时
服务Service(后台)200秒onCreate/onStartCommand等关键生命周期执行超时
ContentProvider10秒(较新版本)首次启动时Provider初始化超时

为什么输入事件是5秒,后台服务却可以长达200秒?因为系统对"用户可感知"的敏感度不同。输入事件每延迟一毫秒用户都能感觉到,而后台服务的执行对用户来说并不可见,所以系统给了更宽裕的时间。

这里有个容易被忽略的细节:这些阈值在不同Android版本上并不完全一致。从Android 12开始,部分厂商定制系统甚至把输入事件的ANR阈值调整为了3.5秒或6秒,所以你在真机上测ANR时,不同机型触发时机可能不一样,排查时不必死扣"为什么这个卡了4秒没报ANR,那个卡了3秒就报"。

2.2 ANR判定流程:从"卡顿"到"系统弹窗"的完整链路

系统判定ANR的链路大致可以拆成这么几步:

第一步,事件投递。InputDispatcher把输入事件投递给应用进程的主线程消息队列。投递时会记录一个投递时间戳。

第二步,超时检查。系统会启动一个超时监控机制,如果到时间后事件仍然没有被消费完(即应用没有将事件标记为"已处理"),InputDispatcher就会向system_server进程发出ANR通知。

第三步,进程状态收集。system_server接收到通知后,会通过ActivityManager、WindowManager等系统服务收集目标进程的CPU使用率、内存状态、线程堆栈等信息。这个过程会调用Process向目标进程发送SIGQUIT信号,触发应用的ANR处理逻辑。

第四步,生成ANR报告。系统将收集到的CPU占用、主线程堆栈、Binder线程池状态等信息写入/data/anr/目录下的trace文件,并弹出无响应对话框。

这里有个关键认知:ANR本质上不是"应用卡了多久"的问题,而是"主线程在系统限定的时间内有没有完成指定任务"的问题。换句话说,系统只看结果,不管你是真的在处理任务还是因为死锁卡住了,没完成任务就统一按ANR处理。

2.3 主线程消息循环的使命:为什么它一堵,整个应用就瘫痪

要彻底理解ANR,你还需要知道主线程(UI线程)在Android应用里的角色。主线程运行着一个无限的消息循环(Looper),专门负责处理与UI相关的所有消息——用户输入事件、绘制请求、生命周期回调、广播分发等等。

用一个生活化的类比来说,主线程就像是餐厅里唯一的大厨兼传菜员。他既要接单(处理输入事件),又要炒菜(执行业务逻辑),还要摆盘(触发渲染)。一旦他在某一桌菜上耗时太多,后面所有单子全部积压,客人(用户)的表现就是"我叫服务员没人理我"。

这也解释了为什么规范要求所有耗时任务都不应该放在主线程:不是主线程本身很弱,而是它的职责太特殊了。一旦主线程被占住,即使你在子线程里已经把数据加载好了,也没有人负责把数据刷新到界面上——因为刷新UI本身也要通过主线程的消息队列。

3. ANR的产生根源:四类最常见的主线程灾难现场

3.1 主线程上的耗时操作:I/O、计算与JSON解析的"夺命连环"

这是ANR产生的最直接原因,也是最容易在代码评审中被发现的一类。典型场景包括:

  • 在主线程执行SharedPreferencescommit()apply()在高频写入时触发的磁盘I/O阻塞(apply()虽然异步写盘,但在低版本上有可能导致QueuedWork.waitToFinish()等待);
  • 在主线程使用BitmapFactory.decodeStream()解码大图,尤其是没有设置inSampleSize的情况下,一张几MB的图片就能让主线程卡上几百毫秒到几秒;
  • 在主线程直接做JSON序列化/反序列化,服务端返回一个几十万条数据的大列表时,Gson.fromJson()可能耗时数秒;
  • 在主线程用SQLiteDatabase做查询,数据库数据量大且没有建索引时,一个全表扫描就能轻松突破ANR阈值。

很多人会觉得自己项目里没这么严重,但请注意一个现实:低端机和中端机的CPU性能差距可以达到3-5倍。你在开发机上跑50ms的耗时操作,在用户的低端机上可能就是250ms,再叠加内存抖动导致的GC暂停、系统后台其他进程的竞争,一次输入事件处理时间超出5秒的ANR就出现了。

3.2 锁竞争与死锁:不是"卡住了",而是"永远等不到"

相比耗时操作,锁竞争和死锁更难排查,因为它的卡顿状态是"隐藏"的——主线程的堆栈可能看起来并没有在做耗时操作,而是停在某一个等待位置。

举个例子:

public class DataManager { private static final Object LOCK = new Object(); public void updateData() { synchronized (LOCK) { // 耗时写入操作 writeToDatabase(); } } public static void onMainThreadCall() { // 主线程执行 new Thread(() -> { synchronized (LOCK) { // 子线程持锁并执行耗时逻辑 SystemClock.sleep(6000); } }).start(); // 主线程也尝试获取锁 synchronized (LOCK) { refreshUI(); } } }

上面这段代码的问题非常典型:子线程持有了锁,并且在锁内执行了6秒的耗时操作,主线程在等待锁的过程中无法进行任何UI刷新,最终触发ANR。这类问题在并发模型复杂的项目里很常见,尤其是使用自己维护的线程池、全局锁或者单例对象时。

死锁则是锁竞争的极端形式——两个线程各自持有一把锁,同时又在等待对方释放锁。一旦主线程参与其中,就是死局。

3.3 Binder调用阻塞:跨进程通信的"隐形杀手"

如果说锁竞争是程序员自己挖的坑,那Binder阻塞更多时候是系统级别的"意外"。Android应用和系统服务之间的几乎所有通信都是通过Binder完成的,比如ActivityManagerServicePackageManagerServiceWindowManagerService

一个典型的场景是:主线程调用Context.getSystemService()ActivityManager.getRunningAppProcesses(),这些调用需要通过Binder和system_server进程交互。如果系统服务端此时负载过高(比如后台大量应用同时启动),Binder调用的返回就会变慢,主线程阻塞在Binder.transact()上。

另一个容易踩坑的案例是主线程调用LocationManager.getLastKnownLocation()SensorManager的相关接口,这些系统服务可能在底层有锁或阻塞逻辑,跨进程通信的耗时被放大后,主线程就"不知不觉"地变成了等待状态。

3.4 系统级压力与组件异常:你的App可能只是受害方

最后一种情况是最"冤"的:你的应用代码本身没有明显问题,但系统整体负载过高,导致ANR。

这些场景包括:

  • 低内存(Low Memory)情况下,系统频繁触发lmkd(低内存杀手)清理进程,应用在等待系统响应时就被判定为ANR;
  • CPU被其他应用或系统进程占满,主线程虽然在做正常操作,但调度不到CPU时间片;
  • 外部存储I/O异常,比如存储卡损坏或文件系统挂起,导致所有文件操作都阻塞;
  • 系统服务自身发生ANR或死锁,Binder响应集体超时。

这类ANR在线上占了相当大的比例,但往往无法通过代码优化彻底解决。这也是为什么"只看自身代码"的排查思路是有局限性的——你必须把系统当时的负载状态一起纳入分析。

4. ANR现场的证据采集:第一次拿到手的有效资料

4.1 系统在ANR发生时到底生成了什么

当ANR发生时,Android系统会在设备上生成一份关键证据文件,路径一般是/data/anr/,文件名类似:

  • traces.txt(各版本可能有差别,有些版本会命名成traces-xx.txt或多个分片文件)
  • 部分版本还会同时生成/data/system/dropbox/下的ANR事件记录,通过dumpsys dropbox命令可以查看到。

普通应用无法直接读取/data/anr/目录,需要系统权限或开发环境支持。在Android 8.0以后,traces.txt的收集机制有了一些变化,系统会将堆栈信息写进"deadline"文件中,并结合system_server侧的DropBox记录来判定具体原因。

Trace文件的核心内容分为几个部分:头部是系统时间、进程信息、ANR发生类型;中间是各个进程的CPU使用率汇总;最后是目标进程的所有线程堆栈。其中最有价值的就是主线程堆栈和CPU负载汇总。

4.2 开发者能拿到的几类日志来源

如果你有源码、有测试机,ANR复现后可以通过以下方式拿到日志:

# 导出最新一份ANR trace文件(需要root权限或可调试应用) adb shell "su -c 'cat /data/anr/traces.txt'" > traces.txt # 查看DropBox中的ANR记录 adb shell dumpsys dropbox --print | grep -A 100 "anr" # 查看系统日志中ANR相关的关键行 adb logcat -b system | grep -E "ANR|not responding|am_anr" # 获取CPU和内存历史状态 adb shell cat /proc/stat adb shell cat /proc/meminfo

如果是在线上,没有root权限时,可以通过第三方监控组件(如FCM/友盟/腾讯bugly等)自动捕获ANR堆栈,不过这些平台捕获的堆栈通常是简化版,只包含主线程,且没有CPU负载汇总。所以有条件的话,我还是建议在测试阶段就手动导出完整trace,能够看到的信息量完全不是一个级别。

4.3 trace文件里最关键的信息怎么读

一份典型的ANR trace,最值得关注的是这几个关键区域:

第一,头部信息中的"Subject"字段,它明确写了ANR的类型:

Subject: Input dispatching timed out (Waiting because the touched window has not finished the previous input event) Subject: Broadcast of Intent { act=xxx } timed out Subject: Executing service bind timeout

第二,CPU负载汇总区域,格式类似:

CPU usage from 16894ms to 0ms ago: 12% 2345/system_server: 8.2% user + 3.8% kernel / faults: 123 48% 4567/com.example.app: 41% user + 7% kernel / faults: 2345 27% 789/cc1: 15% user + 12% kernel

这块非常有用——它告诉你在ANR发生的时间窗口内,谁在大量消耗CPU。如果你的应用占了48%,那大概率是应用内部逻辑确实在执行繁重任务;如果应用只占5%,而system_server占了12%且某个后台进程占用了40%,那可能是系统级问题。

第三,主线程的堆栈信息,通常出现在进程线程列表的第一项(线程名为"main")。如果能看到类似java.lang.Thread.sleepandroid.os.MessageQueue.nextBinder.transact()等调用,就已经找到一半线索了。

5. 从trace还原卡顿现场:实战排查链路

5.1 第一步:先看主线程在做什么,排除"伪ANR"

很多人拿到trace文件就直接翻主线程堆栈,这个方向是对的,但有一个前提需要先排除:主线程堆栈如果停在MessageQueue.next()或者epollWait,说明主线程其实是在空等,并没有在干活

这种情况下,ANR的真正原因往往不在主线程本身,而在"事件没有人处理"或"窗口状态不对"。常见的情况有:

  • 前一个输入事件被某个耗时的onTouchEvent/onClick卡住了,后续事件排队等待;
  • Choreographer的帧回调队列被Draw/Render占满,渲染管线迟迟没有完成上一帧;
  • 系统窗口焦点处理异常,输入事件被派发到了错误的窗口。

遇到这类情况,需要往前翻堆栈,找到当前待处理事件、前一事件的执行时长,再结合CPU负载判断是不是某个过程耗时过长。

5.2 第二步:用CPU负载区分"真忙"与"假死"

我排查ANR时有一句口头禅:先看CPU,再看堆栈,最后才动代码

如果你的trace文件中显示应用进程的CPU使用率很高(比如超过80%持续很久),说明代码大概率在执行密集计算或大量I/O。此时主线程堆栈会显示在某个具体的方法上,这个方法的耗时就是ANR根源。

如果CPU使用率很低(比如不到5%),但主线程同样卡住,那就偏向三类问题:

  • 锁等待:堆栈上会有synchronizedLockSupport.parkObject.wait等标记,并且能看到"waiting to lock"的引用;
  • Binder阻塞:堆栈停在BinderProxy.transact(),并且长时间没有返回;
  • 系统进程故障:CPU高占用出现在system_server端,应用只是被连累。

5.3 第三步:锁等待类堆栈的实战分析法

锁等待的堆栈通常长这样:

"main" prio=5 tid=1 Blocked at com.example.DataManager.updateData(DataManager.java:45) - waiting to lock <0x0465a8c8> (a java.lang.Object) at com.example.MainActivity.onResume(MainActivity.java:88) at android.app.Activity.performResume(Activity.java:8012) ...

看到"waiting to lock"时,下一步怎么找持锁线程?在同一个trace文件里搜索<0x0465a8c8>这个对象引用,定位哪个线程持有它:

"Thread-3" prio=5 tid=13 Native at com.example.DataManager.writeToDatabase(DataManager.java:23) - locked <0x0465a8c8> (a java.lang.Object) at com.example.DataManager$1.run(DataManager.java:57)

找到了!Thread-3持有着这把锁,并且卡在了数据库写入操作上。此时你就能判断:主线程是在等待子线程的数据库操作完成。顺着这条线继续翻Thread-3的完整调用栈,看看具体数据库操作为什么这么慢。

这是我个人最推荐的一种trace排查方式——不要在堆栈里凭经验猜,而是通过锁对象的地址把整个线程间关系还原出来

5.4 第四步:从系统事件链还原整段时间轴

trace文件只是一张"快照",它只记录了ANR判定时刻各线程的状态。但要完整理解问题,还需要把事件链串起来。建议收集以下几项数据:

  • logcat中ANR发生前10秒的日志,看看主线程执行了什么操作;
  • am_anr事件在events缓冲区的记录,包含进程名、ANR类型、系统时间;
  • 当前Activity的状态和前后台情况;
  • dumpsys activity processes中的进程优先级、LRU列表位置等信息。

有了这些时间轴信息,你才能回答"主线程在卡住前的几秒到底执行了什么"这个核心问题。例如,某次线上ANR,主线程堆栈停在Handler.dispatchMessage,但前面的log显示它刚刚收到了一条来自子线程的消息,消息里携带了一个超大的Bitmap。如果没有日志辅助,这个因果链很难定位。

6. 修复与防复发的实际经验:从"修一次"到"防一年"

6.1 针对耗时操作的改造方案:卸载主线程

最直接的修复方案就是把耗时操作从主线程挪出去。这里分享几个经过项目验证的思路:

第一,对于I/O操作,统一收口到一个SingleThreadExecutor或协程的IO调度器中,避免各业务线各自开线程,导致I/O并发过多反而加剧存储竞争。

val ioDispatcher = Executors.newFixedThreadPool(2).asCoroutineDispatcher() lifecycleScope.launch(ioDispatcher) { val data = database.query() withContext(Dispatchers.Main) { render(data) } }

第二,对于JSON解析等CPU密集型任务,用异步方式执行,并充分考虑列表分页或懒加载。如果一个列表几百条数据都包含大量嵌套字段,建议服务端做裁剪,或者客户端改为分页拉取。

第三,对于Bitmap加载,务必使用GlideCoil等成熟图片库,并设置合理的占位图和尺寸压缩。手写BitmapFactory时也至少要使用inSampleSize

6.2 针对锁冲突的修复策略:减少临界区、避免嵌套锁

锁冲突的修复并不只是"加个锁",而是重构锁的使用方式:

  • 缩小同步块的范围,只在需要保护的资源操作内加锁,不要在锁内执行I/O;
  • 使用ReentrantReadWriteLock替换部分读多写少的场景;
  • 避免持锁调用主线程或者回调到主线程的逻辑;
  • 对于单例初始化和数据缓存,可以使用ConcurrentHashMap#computeIfAbsentDCL(双重检查锁)优化。

另外,强烈建议在代码评审阶段就约定一条纪律:主线程上不允许出现synchronized关键字。如果确实需要等待子线程执行结果,应该使用回调、协程、LiveData等异步通信方式,而不是让主线程去"等"。

6.3 Binder阻塞的规避:能不进主线程的IPC就不进

规避Binder阻塞的最有效办法就是避免在主线程上直接调用可能会阻塞的Binder方法。一个很好的例子是PACKAGE_MANAGER相关查询:PackageManager.getInstalledPackages()在某些版本上会在主线程上做大量包信息收集工作,耗时能轻松超过百毫秒,多次调用加起来就可能触发ANR。

对于必须使用的系统服务调用,建议全部包装成异步接口,并设置合理的超时重试机制。对线上用户的getSystemServiceregisterReceiver调用进行监控,如果发现主线程Binder调用耗时超过200ms的占比升高,就要警惕起来。

6.4 线上监控与兜底:把ANR从"发生后补救"变成"发生前预警"

修复只是开始,真正的重点是把ANR控制在一个稳定的低水平。我在这块主要做了三件事:

第一,接入ANR监控组件,按渠道、版本、进程、机型维度聚合ANR率。自定义的监控组件会通过Looper.getMainLooper().setMessageLogging打印主线程消息的dispatch耗时,能捕获到系统ANR之前的"慢消息"。

第二,建立"ANR前置预警"机制——主线程单次消息耗时超过2秒就上报一次"卡顿"事件。卡顿事件的样本量是ANR的10倍以上,能更早暴露风险点。

第三,在发布V2版本时,加一个"ANR兜底方案":当监控组件检测到主线程连续5条消息都耗时超过500ms时,主动触发一次轻量级降级,比如关闭动画、暂停一些非核心任务,确保用户界面还能响应。

6.5 复盘方法论:把每一条ANR都变成改进项

最后说一个容易被忽略但非常重要的习惯:每条ANR都值得一个完整的复盘记录。我的做法是维护一份"ANR复盘表格",包含以下字段:

字段说明
现象描述用户或测试反馈的直观表现
系统日志摘要trace中Subject字段、CPU负载、主线程堆栈
根本原因分类耗时操作/锁竞争/Binder阻塞/系统级问题
影响版本与机型便于判断是否与适配有关
修复方案代码改动点、自测结果
线上验证灰度发布后的ANR率变化

每当一条ANR被标记为"已解决",我们还会在代码库中做一个ActionChecklist,确保类似的代码模式不会再次被引入。比如,"主线程禁止做超过100ms的I/O操作"这类规则,可以通过静态代码扫描或IDE插件在提交前拦截。

7. 关于ANR排查,说点文档里不会写的实在话

每次聊到ANR,总能看到有人问"有没有什么黑科技能一键定位"。说实话,我做了这么多年性能治理,ANR排查始终是一个"工程素养"的活儿——它考验的是你对Android系统机制的熟悉程度、对日志的敏感度,以及面对复杂并发问题时抽丝剥茧的耐心。

几个软性经验分享给大家:

第一,不要太依赖日志平台给的"ANR堆栈"。很多平台只上传主线程堆栈,容易把问题定位到错误的方法上。我见过太多因为平台堆栈指向了某个通用方法,导致开发改了一圈代码也没实际解决问题的案例。尽量拿到完整trace和CPU负载。

第二,低端机测试机不能省。如果条件允许,备一台几百元的低端Android机,专门用来压测性能。很多高端机永远触发不了的ANR,在低端机上就是家常便饭。特别是那些老芯片的设备,CPU性能和内存带宽的瓶颈会被放大到无法忽视。

第三,对"系统级ANR"要有容忍度。即使你的代码写得很干净,线上也一定会有少量ANR是系统自身问题导致的。关键在于你能不能在trace里把责任划分清楚。如果能够在复盘时给出"该ANR由system_server高负载导致,非应用逻辑问题"的结论,那比无脑优化代码要有说服力得多。

第四,ANR修复真正难的不是技术,是坚持。一次ANR的修复相对容易,难的是在每个需求迭代中保持对主线程健康的敬畏。我见过太多项目,性能优化做得不错,但新需求一上线,又有开发把网络请求直接写进了onClick里,然后等线上报警了再来复盘。

说到底,ANR就是应用性能瓶颈最直观的报警器。它把主线程的每一次"失职"都变成了系统层面的用户可见投诉。只有把机制搞清楚、日志会用、排查链路跑顺、修复方案跟上,你的应用才能在各种机型上真正做到稳定流畅。

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

数据中心动环保护选哪个牌子?六大品牌技术实力与选型深度解析

当一座数据中心承载着数十亿次运算请求、上千架服务器昼夜运转时&#xff0c;动力环境监控与保护系统&#xff08;动环保护&#xff09;早已不是“锦上添花”的辅助工具&#xff0c;而是直接关联业务连续性的核心防线。一次精密空调的异常停机、一回配电柜的局部过热、一处毫不…

作者头像 李华
网站建设 2026/9/9 10:46:43

Hermes-Agent:轻量级任务协调器的工程实践

1. Hermes-Agent 不是“新AI Agent框架”&#xff0c;而是轻量级任务协调器的务实实践最近在几个技术社区和内部项目复盘会上&#xff0c;反复看到“hermes-agent”这个词被提起——不是作为某个大厂开源的新一代Agent框架&#xff0c;也不是什么带LLM推理引擎的智能体平台&…

作者头像 李华
网站建设 2026/9/9 10:45:37

ML-KWS-for-MCU源码级解析:Cortex-M上的语音唤醒与边缘AI工程实践

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

作者头像 李华
网站建设 2026/9/9 10:45:08

软考中级软件设计师备考全攻略:考点拆解、真题实战与避坑指南

我当年准备软考中级软件设计师的时候&#xff0c;第一反应是买教材、搜真题、找网课&#xff0c;结果资料越攒越多&#xff0c;人却越来越没底。后来把它当成一次系统性的知识体检去复习&#xff0c;才慢慢找到节奏。很多人在决定考不考、怎么考之前&#xff0c;都卡在“信息过…

作者头像 李华
网站建设 2026/9/9 10:44:39

PyQt5现代化桌面应用开发实战:界面美化、HTML嵌入与视频接入

写Qt程序这几年&#xff0c;我最大的感受就是&#xff1a; PyQt5 远比很多人想象中要强大和现代化。很多人一提起用它&#xff0c;脑子里还是那个默认的灰底白字老界面&#xff0c;但实际上&#xff0c;借助 QSS 样式表、无边框窗口、WebEngine 嵌入这些组合拳&#xff0c;完…

作者头像 李华
网站建设 2026/9/9 10:42:44

面向AI协同的嵌入式软件开发范式:状态机与提示词工程实践

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

作者头像 李华