news 2026/10/2 18:48:36

Android运行时Byte Buddy泛型代理实战:从类加载到DEX转换

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android运行时Byte Buddy泛型代理实战:从类加载到DEX转换

要论Java字节码工具里最“好用但最难用”的,Byte Buddy绝对榜上有名。它能把动态生成类的复杂度压到最低,让反射和动态代理都显得笨重;可一旦你把这套东西往Android运行时上搬,再碰到泛型,那画风就完全变了——类加载策略失灵、Signature属性丢失、DEX转换踩坑、验证器报错一个接一个。

我最近在一个移动端基础架构项目里做运行时AOP和泛型代理的预研,Java端一切顺利,换到Android端直接翻车了一整个星期。很多问题不是搜不到答案,而是搜到的东西要么只讲JVM场景,要么把Android当成“能跑的Java”处理,导致走了大量弯路。这篇文章就围绕“Android运行时”和“泛型”这两个最让人头大的点,把我踩过的坑、验证过的做法、能直接抄的代码全部梳理出来,算是给自己做个备忘,也帮后面做同类事情的兄弟省点时间。

能把这篇文章认真看完的人,我默认你已经有Byte Buddy的基本功了:会写DynamicType.Builder,知道怎么用method()、intercept()、to()、ClassLoadingStrategy这一套。如果没有,建议先跑一遍官方文档的入门例子再回来。下面内容全是进阶向的实战记录。

1. 为什么Byte Buddy在Android上总比JVM上多踩几个坑

1.1 没有JVM的命,却有JVM的依赖

Byte Buddy本质上是按JVM规范生成的是.class字节码,它服务的对象是JVM:HotSpot、OpenJ9都行,反正一套字节码丢进去类加载器就能解析。

但Android运行时的类加载器不认普通JVM字节码。ART和Dalvik只理解DEX格式。这意味着Byte Buddy动态生成的类,在Android上不能像在Java里那样直接load()并用ClassLoadingStrategy.Default.WRAPPER就完事。你得到的是byte[],但这个byte[]对Android来说是一堆无法识别的数据。

这种“平台错位”是80%的Android端集成问题的根源。很多人一上来就按Java端的写法操作,结果在模拟器里各种ClassNotFoundException、VerifyError、NoClassDefFoundError,代码明明生成成功,加载时却总在最后一公里翻车。

1.2 官方文档给的ClassLoadingStrategy,到Android就废了一半

Byte Buddy官方文档里推荐的ClassLoadingStrategy.Default.WRAPPER、CHILD_FIRST在JVM上非常好用,但在Android上,它们依赖的是java.lang.ClassLoader的标准定义机制。Android的ClassLoader体系被重写过了,defineClass的调用路径、字节码校验逻辑、内部类处理机制全不一样。

最关键的一点:Android的BaseDexClassLoader走的是DexPathList,它只认DEX容器,不接受裸的.class字节数组。所以Byte Buddy那套默认加载策略在Android上基本是摆设,除非你自己介入把字节码转成DEX,再丢给DexClassLoader。

这里就要先明确一个概念:我们所谓的“Android运行时”,不是让Byte Buddy在运行时直接加载JVM字节码,而是要在运行时完成一条JVM字节码 → DEX字节码 → 类加载的流水线。谁把这条流水线想明白了,后面的事都是细节。

1.3 构建期 vs 运行时:先想清楚你的类在哪里“出生”

在Android上使用Byte Buddy,其实要分两种完全不同的场景,很多人没搞清楚就在滥用运行时方案:

构建期生成:Gradle构建时,用Byte Buddy生成JVM字节码,然后通过D8/R8编译成DEX打包进APK。这种方案里Byte Buddy只是一个“代码生成器”,整个过程和Android运行时没有任何关系。适合实现代码模板化、批量生成接口实现类、按需定制业务逻辑,稳定性和性能都不用太担心。

运行时生成:App跑起来之后,在内存里创建新类。这就必须自己处理JVM字节码到DEX的转换,还要自己管理类加载器,不然类压根活不下来。

我的建议是:能用构建期解决的,别用运行时。但有时候我们确实需要在运行时根据云端配置动态生成类型,那就得把下面这套打通。

2. 先搞定运行时的“类加载”这个拦路虎

2.1 运行时转DEX:D8库的正确打开方式

Android官方已经把D8开源了,代码在com.android.tools:r8这个artifact里。你可以在运行时把它当成一个普通Java库调用,直接把Byte Buddy生成的byte[]转成DEX字节码。

核心思路非常简单:构造一个D8Command,传入class文件字节数组,设置输出路径或输出流,然后调用D8.run()。拿到的就是一份DEX字节流,这时候再交给自定义的DexClassLoader去加载,类就活了。

伪代码大概是这种形态:

// 1. Byte Buddy生成JVM字节码 byte[] classBytes = new ByteBuddy() .subclass(Object.class) .name("com.example.Generated_" + System.currentTimeMillis()) .make() .getBytes(); // 2. 包装成D8能识别的ProgramResource ProgramResource resource = ProgramResource.fromBytes( ClassKind.CF, "com/example/Generated_123.class", classBytes); // 3. 集合资源 + 配置输出 DexIndexedConsumer consumer = new DexIndexedConsumer.DirectoryConsumer(outputDir); D8Command command = D8Command.builder() .addProgramResourceProvider(new ProgramResourceProvider() { @Override public Collection<ProgramResource> getProgramResources() { return Collections.singletonList(resource); } }) .setOutput(consumer, null) .build(); D8.run(command); // 4. 从输出目录里读取classes.dex,交给DexClassLoader

D8版本要和Android Gradle Plugin保持一定兼容性,我测试时用的com.android.tools:r8:3.0.75(对应AGP 7.x),没问题。但不同版本之间的API还是有细微改动,建议锁定一个版本不要随便升。

这里有个容易忽略的坑:D8输出的时候可能会顺手做不少优化,如果你后续还要做热修复或增量加载,最好把--no-minification、--no-desugaring等关掉,否则你生成的方法名、泛型签名可能在DEX阶段就被改得面目全非。

2.2 自定义ClassLoader:让生成的类在Android里活起来

拿到DEX字节码之后,加载它需要自己写一个轻量ClassLoader。Android的DexClassLoader要求传入的是DEX文件路径,不是字节数组,所以最简单的方式是先把DEX字节码写到应用私有目录,再加载:

File dexDir = new File(context.getCacheDir(), "generated_dex"); dexDir.mkdirs(); File dexFile = new File(dexDir, "generated_" + System.currentTimeMillis() + ".dex"); FileUtils.writeByteArrayToFile(dexFile, dexBytes); DexClassLoader loader = new DexClassLoader( dexFile.getAbsolutePath(), dexDir.getAbsolutePath(), null, context.getClassLoader()); Class<?> generatedClass = loader.loadClass("com.example.Generated_123"); Object instance = generatedClass.getDeclaredConstructor().newInstance();

很多人在这一步踩了ClassNotFoundException,不是DEX出了问题,而是父子类加载器的parent传错了。传context.getClassLoader()意味着生成的类可以访问App自身的所有类和资源,但反过来也会造成“父加载器看不到子加载器里的类”的问题。如果生成的类要实现App里某个接口,这个传法没问题;如果生成的类要被App里的逻辑反向引用,那就得注意方向。

2.3 ClassLoader隔离:别再让你的字节码撞鬼

运行时动态生成类有一个很隐蔽的问题:同一个类名如果被加载两次,第二次永远是ClassAlreadyLoadedException。这在JVM上就很难排查,在Android上更难,因为Android里同名的类可能已经被系统的PathClassLoader预加载过了——比如你生成的类恰好叫com.example.Foo,而App的原生DEX里正好有同名类。

我的习惯是在生成类名时做两层保险:一是在包名里带一个不可预料的随机段,比如com.example.gen.a1b2c3.ProxyImpl;二是生成之前先通过当前类加载器findLoadedClass做一次探测,明确告诉Byte Buddy如果类已存在就重新换一个名字。

这样虽然会有极小的类名泄漏风险,但换来的是稳定性和可复现性,在线上环境里活下来比什么都重要。

2.4 debug与release的差异:混淆、FileProvider和路径全部排查

还有一类和Android强绑定的坑,看起来和字节码没关系,但实际能把人卡死:

  • Release包开启混淆后,你的入口类、接口名会被R8改写,Byte Buddy脚本如果是基于字符串匹配方法名或类名,就会原地失联。应对办法是给入口类和接口加@Keep注解,并且用TypeDescription引用而不是字符串常量。
  • 文件路径访问问题:DEX文件往哪写、从哪读,都是私有目录。如果你把生成的临时DEX写到外部存储,再用content://这种URI让另一个进程读取,经常会碰到权限和路径校验问题,处理不好就各种FileNotFoundException。我测试时遇到过挂在/storage/emulated/0/Android/data/下的目录被FileProvider拦截、DEX读取失败的情况,最后全部改成context.getCodeCacheDir()或cacheDir,才彻底解决。
  • 签名校验问题:系统在加载APK时会校验DEX完整性,但运行时通过DexClassLoader加载的DEX不受这个校验限制,所以你在内存里改字节码再转DEX,这条路是合法的。

3. 泛型迷局:Byte Buddy对泛型的处理机制

3.1 泛型不会凭空消失,它被塞进了Signature属性

Java的泛型是编译期语法糖,运行时确实会被擦除。但擦除不是全毁,编译器会额外生成一个Signature属性,保存类、方法、字段上的泛型信息。反射里的getGenericSuperclass()、getGenericInterfaces()、getTypeParameters()能拿到泛型,靠的就是这个属性。

Byte Buddy生成类时,需要显式地在字节码层面写入这个Signature属性,否则生成的类在源码级别看起来是带泛型的,实际运行时反射一查,全是裸类型。这也是很多人说“Byte Buddy不支持泛型”的核心误会——它支持,只是要你用对API。

Signature属性是纯“签名信息”,不参与四字节码符号解析,也不影响类的正常执行。可一旦Framework或反射依赖泛型信息做类型判断,比如Gson、Jackson、Retrofit的TypeToken机制,签名丢了就完蛋,拿到的泛型全变成Object。

3.2 Byte Buddy的类型体系:TypeDescription与Generic层次

要玩转Byte Buddy泛型,先要分清它里面的几个核心抽象:

  • TypeDescription:描述一个已加载或未加载的类型,它本身是TypeDefinition的子接口。
  • TypeDescription.Generic:描述“带泛型信息的类型”,也就是真正和Signature属性对接的接口。
  • TypeDescription.Generic.OfNonGenericType:普通非泛型类型。
  • TypeDescription.Generic.OfParameterizedType:参数化类型,比如List<String>。
  • TypeDescription.Generic.OfTypeVariable:类型变量,比如T extends Serializable。

为什么搞这么复杂?因为Byte Buddy需要同时处理“来源类型”和“生成类型”两套信息。你定义父类时用的TypeDescription只是“基本盘”,当你想让生成的父类带有BaseRepository<User>这种参数化签名时,就得用TypeDescription.Generic来描述。

实际操作时,最常用的入口是TypePool。它可以读取已有类的泛型签名:

TypePool typePool = TypePool.Default.ofSystemLoader(); TypeDescription.Generic genericDescription = typePool.describe("com.example.BaseRepository") .resolve() .asGenericType();

拿到Generic描述之后,你可以通过typeArguments()读取三个类型参数,通过findVariable("T")定位具体某个类型变量,通过getSuperClass()递归查看父类型。这套东西就是你在写字节码生成逻辑时的“数据源”。

3.3 泛型丢失的四种典型时刻

我总结了最容易丢泛型信息的几个时机,基本覆盖了大家会遇到的所有情况:

**第一种:声明父类时忘了写泛型签名。**比如你想生成class MyRepo extends BaseRepository<User>,如果只传BaseRepository.class给subclass(),生成的类签名里只有BaseRepository,没有<User>。必须用TypeDescription.Generic的方式描述父类型。

**第二种:实现泛型接口时,签名只是“裸接口”。**类似implements Comparator<User>,如果你用implement(Comparator.class),反射拿getGenericInterfaces()只会得到Comparator,而不是Comparator<User>。

**第三种:定义泛型方法和字段时漏写TypeVariable声明。**类级泛型还好搞,方法级的public <T> T convert(T input),要求在方法描述里同时定义类型变量,和字节码里的LocalVariableTypeTable是对应关系,少一个定义就少一层泛型。

**第四种:运行时代码无意中使用了非泛型描述。**比如你从一个DynamicType.Builder里取了getTypeDescription(),然后直接把它当成TypeDescription.Generic去声明父类,结果这里的泛型参数是空的,因为类型还没生成,签名自然还没写进去。这种错误特别隐蔽。

4. 手写一个“泛型代理框架”的完整实例

4.1 需求场景:动态实现一个带泛型的接口

为了让整套流程更直观,我设计一个贴近Android业务的场景:我们要为DataStore<T>接口动态生成代理实现,这个接口带一个类型变量T,并且要求生成的代理类能够在Android运行时被加载和实例化,反射时还能拿到完整的泛型签名。

接口长这样:

public interface DataStore<T> { T load(String key); void save(String key, T value); List<T> loadBatch(List<String> keys); }

目标:生成一个DynamicDataStore<T>类,在运行时由Byte Buddy构造,然后实例化并调用它的load方法。这里的难点是,方法签名里有T,还有List<T>这种嵌套参数化类型,签名里的类型变量必须和类声明的T正确对应。

4.2 定义类的泛型参数:TypeVariable定义

先从定义一个带泛型参数的类开始。Byte Buddy没有特别简化的API,你要通过TypeVariable相关的TypeDescription.Generic.Builder来构造:

TypeDescription.Generic.Builder typeVariableBuilder = TypeDescription.Generic.Builder.ofTypeVariable("T");

ofTypeVariable创建的是T自身,你还可以给它加边界。比如要让T extends BaseEntity:

TypeDescription.Generic boundType = TypeDescription.Generic.OfNonGenericType.ForLoadedType.of(BaseEntity.class); TypeDescription.Generic.Builder typeVariableBuilder = TypeDescription.Generic.Builder.ofTypeVariable("T", boundType);

然后把类型变量放进子类的声明参数列表里:

DynamicType.Builder<?> builder = new ByteBuddy() .subclass(Object.class) .name("com.example.gen.DynamicDataStore") .typeVariable(typeVariableBuilder.build());

4.3 给实现类声明父类型/接口签名

接着声明接口。这里不能再直接传DataStore.class了,因为那样会丢掉<T>。要用TypeDescription.Generic描述参数化版本:

TypeDescription.Generic dataStoreType = TypeDescription.Generic.Builder .parameterizedType( TypeDescription.ForLoadedType.of(DataStore.class), typeVariableBuilder.build()) // DataStore<T> .build(); DynamicType.Builder<?> builder = new ByteBuddy() .subclass(Object.class) .name("com.example.gen.DynamicDataStore") .typeVariable(typeVariableBuilder.build()) .implement(dataStoreType);

这样一来,生成的类从字节码层面就带上了implements DataStore<T>的完整签名。

4.4 泛型方法与参数化字段的生成

接下来是拦截loadBatch方法,它返回List<T>。这个方法来自接口,Byte Buddy会自动生成桥接结构和默认实现,但如果你想完全控制返回类型和签名,就要用MethodDescription的泛型描述:

MethodDescription.Generic methodDesc = dataStoreType.methods() .filter(ElementMatchers.named("loadBatch")) .getOnly() .asDefined(); DynamicType.Builder<?> result = builder .method(ElementMatchers.named("loadBatch")) .intercept(MethodDelegation.to(DataStoreInterceptor.class)) .defineMethod("loadBatch") .withParameters(List.class) .throwing(NoSuchFieldException.class) .intercept(...);

方法拦截器里,我们要从目标实例上取一个字段,并做类型转换。这里有一个容易踩的小坑:由于泛型被擦除,运行时T实际是Object,所以你在拦截器里强转类型时,要拿泛型签名判断,别直接用String.class之类的具体类型:

public class DataStoreInterceptor { public static Object loadBatch(@Origin Method method, @AllArguments Object[] args, @This Object target) throws Exception { Type genericReturnType = method.getGenericReturnType(); // 判断是否是ParameterizedType,并取到实际类型参数 if (genericReturnType instanceof ParameterizedType) { Type actualArg = ((ParameterizedType) genericReturnType) .getActualTypeArguments()[0]; // 按actualArg做类型转换 } return new ArrayList<>(); } }

4.5 生成结果验证:javap、反射、getGenericXxx

生成完之后,最关键的一步是验证,不能只看能不能跑通。我习惯先用Byte Buddy拿到byte[],反解成TypeDescription看一眼Signature:

byte[] classBytes = result.make().getBytes(); // 用一个独立的TypePool重新解析 TypePool freshPool = new TypePool.Default( TypePool.ClassLoading.ofClassPath()); TypeDescription loadedDesc = freshPool.describe( "com.example.gen.DynamicDataStore").resolve(); TypeDescription.Generic superClass = loadedDesc.getSuperClass(); TypeDescription.Generic interfaceType = loadedDesc.getInterfaces().get(0); System.out.println(interfaceType.getTypeName()); // com.example.DataStore<T>

如果输出是com.example.DataStore<T>,说明泛型签名保住了。如果输出是com.example.DataStore,那就是哪一步用了非Generic描述的API,签名没写入。

再通过反射验证运行时信息:

Class<?> clazz = loader.loadClass("com.example.gen.DynamicDataStore"); Type[] genericInterfaces = clazz.getGenericInterfaces(); for (Type t : genericInterfaces) { System.out.println(t.getTypeName()); }

此时应看到com.example.DataStore<T>。如果看到的是com.example.DataStore,就说明加载的是另一个版本,多半是DEX转换时把签名擦掉了,或者在D8阶段做了不希望做的精简。

5. 常见问题与排查技巧实录

5.1 Class already loaded / 覆盖重复生成

Android场景下,ClassAlreadyLoadedException高频出现。原因通常是同一个类名被DexClassLoader加载后,又用新的DEX加载了一遍。还有一种隐性情况:Byte Buddy的TypePool.Default.ofClassPath()缓存了旧类型,第二次生成即便类名变了,内部引用的父类签名可能还是旧状态。

我的排查顺序是:

  • 先确定重复生成名字是否一致,给生成根路径加上随机段或自增ID;
  • 检查ClassLoader是否仍然持有旧实例,必要时为每次生成新建一个DexClassLoader;
  • 出现诡异状态时,把所有TypePool.Default实例替换成TypePool.Default.ofBootLoader()或每次新建,避免缓存污染。

5.2 NoClassDefFoundError:验证器和DEX预处理惹的祸

Android的NoClassDefFoundError和JVM上的同名异常行为不太一样。很多时候它不是因为类本身缺失,而是验证器发现类结构不合法后拒绝加载。最常见的元凶是把Byte Buddy生成的JVM字节码直接丢给DexClassLoader,没先经过D8转换。

还有一种情况特别隐蔽:生成的类引用了Android Framework里不存在的JVM类,比如你无意中用了java.sql.Connection,JVM上编译没问题,Android上直接裂开。排查时用apkanalyzer或者dexdump看DEX里引用的类型清单,找出来干掉。

5.3 泛型签名丢失:为什么反射getGenericType拿不到T

反射拿不到泛型,大概率是生成的类的Signature属性缺失。你可以用javap -v查看生成的class文件,搜Signature:字段:

Signature: #123 <T:Ljava/lang/Object;>Ljava/lang/Object;Lcom/example/DataStore<TT;>;

如果完全没有这行,说明Byte Buddy生成时压根没写泛型信息。回到构建流程,检查所有涉及泛型声明的地方是不是都用TypeDescription.Generic。

如果class文件里有Signature,但DEX加载后反射拿不到,那问题基本在D8。部分D8版本会把未知的Signature属性丢掉,需要显式保留或调整D8的编译选项。

5.4 R8/ProGuard混淆后泛型与类名映射

这个问题只出现在release包:Byte Buddy是构建期还是运行期,结果可能完全不一样。如果是构建期生成,生成类的产物会和业务代码一起被R8处理,字符匹配的脚本就会全部失效。这里的核心解法是:

  • 所有字节码脚本里引用的类名,统一用Class<?>或TypeDescription,不要用字符串字面量;
  • 给外部入口接口、注解、基类加上@Keep;
  • 如果你在脚本里引用的是具体方法,用ElementMatchers.named("save")这种字符串匹配是脆弱的,最好通过反射先拿到Method引用,再用MethodDescription.ForLoadedMethod.of(...)匹配。

5.5 问题速查表

现象可能原因解决方向
ClassAlreadyLoadedException类名重复,或ClassLoader缓存旧类类名加随机段;每次新建DexClassLoader
NoClassDefFoundErrorJVM字节码未转DEX,或验证器拒绝先用D8转dex,再交给DexClassLoader
反射泛型丢失Signature属性未生成,或D8丢弃检查泛型声明API;关闭D8优化选项
混淆后找不到类R8改写类名加@Keep;用Class引用代替字符串
运行时类内部异常ClassLoader父子关系错位确认parent设为宿主ClassLoader

6. 一些一直想说的经验

回顾这轮踩坑,最大的感悟是:Byte Buddy的问题很少出在“生成”这一环,基本都出在“生成之后的生态匹配”上——Android有自己的类加载规则、DEX格式和混淆体系,泛型有自己独立的Signature属性存储链路。你每引入一个平台特性,都要顺着这条链路确认一遍。

我个人现在的做事习惯是:能在构建期做的绝不拖到运行时,能不用字符串匹配的绝不用字符串,能不依赖默认ClassLoadingStrategy的绝不依赖。Byte Buddy本身是个强大的工具,但它的强大是需要你先把宿主环境吃透才能兑现的。

最后说一个特别实用的小技巧:调试时,直接把Byte Buddy生成的byte[]写到本地文件,再扔到任何Java工程里用javap -v看字节码,比在Android设备上反复打印日志高效太多。泛型、方法签名、桥接方法、异常表、局部变量表,全都可以用这一招快速定位问题。希望这些经验能让你少走几趟弯路,真要是再遇到类加载或泛型的疑难杂症,欢迎回来对着这张速查表逐项排查,大概率能省下你半天时间。

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

智能体安全护栏实战:从1200实例逃逸复盘到企业防护清单

1. 事件复盘&#xff1a;1200 个实例突破生产环境的真实构成先说明一下背景。我接手这次复盘的时候&#xff0c;安全团队给的原始告警只有一句话&#xff1a;“生产环境智能体服务出现异常访问&#xff0c;疑似大规模逃逸”。等我把监控数据、网关日志、模型调用记录全部拉齐之…

作者头像 李华
网站建设 2026/10/2 18:45:42

Figma网页端完全指南:从打开浏览器到团队协作与开发联动

其实我最早接触 Figma 的时候&#xff0c;心里也犯嘀咕&#xff1a;一个只能跑在浏览器里的设计工具&#xff0c;真的能扛住复杂项目的日常使用吗&#xff1f;后来用着用着才发现&#xff0c;网页端正是因为不需要安装、打开即用的特性&#xff0c;成了团队协作和跨设备办公的“…

作者头像 李华
网站建设 2026/10/2 18:45:36

深入浅出掌握 DOM 操作:从节点原理到事件委托实战

写 JavaScript 就别想绕开 DOM。你随便打开一个网页&#xff0c;按 F12 在控制台敲一行document.querySelector(video)&#xff0c;再补一句v.style.rotate -90deg&#xff0c;整个视频立刻横过来——这种“指哪打哪”的爽感&#xff0c;就是 DOM 操作最直观的样子。作为前端开…

作者头像 李华
网站建设 2026/10/2 18:45:36

Docker进阶实战:数据卷、网络、Compose与Swarm避坑指南

很多人学Docker的思路是这样的&#xff1a;先pull一个镜像&#xff0c;docker run跑起来&#xff0c;–p映射个端口&#xff0c;然后就开始用了。等用了两三个月&#xff0c;麻烦事全来了——容器一删&#xff0c;数据跟着没了&#xff1b;服务器重启&#xff0c;容器IP变了连不…

作者头像 李华
网站建设 2026/10/2 18:44:52

Keepalived+HAProxy高可用负载均衡:原理、配置与故障转移实战

凌晨两点半&#xff0c;值班手机震了。“你们的服务是不是挂了&#xff1f;”我揉着眼睛登录跳板机看了一眼&#xff1a;后端某台机器其实已经宕机快两小时了&#xff0c;入口请求却一直正常&#xff0c;用户完全无感。那一瞬间我就知道&#xff0c;之前搭的那套高可用负载均衡…

作者头像 李华
网站建设 2026/10/2 18:43:22

群晖Web Station搭建个人导航站:从零到日常维护全记录

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

作者头像 李华