Android属性系统深度解析:property_get与property_set的权限、陷阱与实战
1. 从一次诡异的系统属性读取失败说起那天下午我正在调试一个与系统启动流程相关的模块。我需要读取一个关键的属性值比如sys.boot_completed来判断系统是否已经完成启动。按照常规我写下了property_get(sys.boot_completed, value, unknown)满心期待能拿到1。然而控制台打印出的结果却是一个空字符串或者更糟是默认值unknown。这让我瞬间警觉起来——系统明明已经启动完毕为什么读不到这个属性这只是一个引子。在 Android 系统开发、ROM 定制甚至是应用层进行一些深度系统交互时property_get和property_set这对看似简单的 C 接口是绕不开的核心工具。它们就像是通往 Android 系统底层“全局变量存储区”的钥匙。你可能会在init进程的.rc脚本里看到它们在SystemProperties这个 Java 类的背后是它们在adb shell里敲下的getprop和setprop命令底层调用的也是它们。但你真的了解它们吗为什么有时候读不到属性为什么在应用层调用SystemProperties.set会抛出SecurityException属性名有哪些命名规范ro.开头的属性真的完全不可变吗这篇文章我将结合源码和多年踩坑经验为你彻底拆解property_get/property_set的机制、使用场景、权限陷阱以及那些官方文档不会告诉你的“潜规则”。无论你是系统开发者、应用开发者还是对 Android 底层感兴趣的技术爱好者理解这套机制都将让你对系统的掌控力提升一个层级。2. 属性系统Android 的全局键值存储与通信总线在深入函数之前我们必须先理解它们所操作的“属性系统”到底是什么。你可以把它想象成一个运行在内存中的、全局可访问的键值对Key-Value数据库。但这个数据库非常特殊全局性与进程间通信IPC所有进程无论是高权限的系统服务如system_server还是普通的应用进程都可以访问读取或设置这个数据库。这使得它成为了一种轻量级的进程间通信IPC机制。例如SurfaceFlinger服务启动后会设置sys.boot_completed1其他关心系统启动状态的进程如一些守护进程通过读取这个属性就能得知事件已发生无需建立复杂的 Binder 连接。持久化与启动顺序属性并非全部 ephemeral临时的。一部分属性在系统启动时会从几个固定的文件中加载并常驻内存最核心的就是/system/build.prop、/vendor/build.prop、/product/build.prop等。这些文件在init进程的早期阶段被解析其内容成为了属性的初始值。此外init还会监听/data分区下的property文件用于持久化那些在运行时被更改、且需要跨重启保持的属性。权限控制模型这是属性系统最复杂也最容易出错的部分。并非所有进程都能修改所有属性。系统通过一套基于 SELinux 和传统 Unix 文件权限uid/gid的精细控制模型来规定“谁可以读/写哪个属性”。我们常遇到的“Permission denied”错误就源于此。通知机制属性系统支持一种“发布-订阅”模式。进程可以调用property_set或相关函数来设置一个属性同时可以触发一个“属性改变”事件。其他进程可以注册监听某个或某类属性通过property_listener或init中的on property:触发器当属性值变化时会收到回调或执行预设的动作。这是实现系统动态配置的关键。理解了属性系统这个“舞台”我们再来看property_get和property_set这两个“演员”是如何在上面表演的。它们的函数签名非常简单// 定义在 sys/system_properties.h int property_get(const char *key, char *value, const char *default_value); int property_set(const char *key, const char *value);property_get尝试获取名为key的属性值如果成功则将值复制到value缓冲区调用者需确保缓冲区足够大通常PROP_VALUE_MAX定义为 92并返回值的长度。如果属性不存在或读取失败则将default_value复制到value并返回其长度。property_set尝试将名为key的属性设置为value。成功返回 0失败返回 -1 并设置errno。注意在实际的 AOSP 源码中更常见的底层接口是__system_property_get和__system_property_set。property_get/set通常是它们的包装或旧版本名称。在 NDK 开发或系统代码中建议查看当前源码树中bionic/libc/include/sys/_system_properties.h或类似头文件以确认可用接口。3. property_get 详解读取的艺术与陷阱读取一个属性看起来是最简单的操作但暗藏玄机。3.1 基础读取与缓冲区管理一个健壮的读取代码应该像下面这样#include sys/system_properties.h #include stdio.h #include string.h void read_system_property() { char value[PROP_VALUE_MAX]; // 关键使用系统定义的宏确保缓冲区大小 int len property_get(ro.build.version.sdk, value, unknown); if (len 0) { // 成功读取value 中包含了属性值 printf(SDK Version: %s (length: %d)\n, value, len); // 注意len 是字符串长度不包括结尾的 \0 } else { // len 0 通常意味着属性值为空字符串或者使用了默认值 printf(Property not found or empty, using default: %s\n, value); } // 另一个例子读取可能不存在的属性 len property_get(my.custom.property, value, default_value); printf(Custom property: %s\n, value); // 输出可能是 default_value }这里第一个关键点就是PROP_VALUE_MAX。在 Android 系统中一个属性值的最大长度被限制为 92 字节包括结尾的\0。这是定义在源码中的硬限制。如果你分配的缓冲区小于这个值并且属性值很长就会导致缓冲区溢出这是严重的安全隐患。所以永远使用char value[PROP_VALUE_MAX]来声明接收缓冲区。3.2 属性命名空间与常见属性解析属性名通常采用点分格式并形成了隐式的命名空间这有助于组织和控制权限ro.只读属性。顾名思义这些属性在系统初始化后理论上不应该被改变。它们通常来自build.prop文件包含了编译时的信息如ro.build.id,ro.product.model,ro.debuggable。init进程会对以ro.开头的属性设置特殊的保护。但请注意这个“只读”是逻辑上的在init进程自身执行的上下文中例如在on early-init阶段是可以设置ro.属性的。一旦过了某个时间点通常是init启动完成再尝试property_set一个ro.属性就会失败。persist.持久化属性。这类属性的值会被自动保存到/data/property/目录下的对应文件中。因此即使设备重启它的值也会保留。例如persist.sys.timezone存储了时区设置。这是实现用户配置跨重启保存的简单机制。ctl.控制属性。这是一个特殊系列用于向init进程发送命令控制服务的启动、停止和重启。例如setprop ctl.start bootanim会命令init启动名为bootanim的服务。setprop ctl.stop zygote会命令init停止zygote服务。设置这些属性不会改变一个存储的值而是触发init执行相应的动作。init进程内部有专门的逻辑来处理ctl.开头的属性。sys.,hw.,dev.等这些是常见的系统运行时属性命名空间用于各个子系统报告状态或配置。例如sys.boot_completed,hw.audio.primary。理解这些前缀能帮助你在浩如烟海的getprop输出中快速定位你需要的属性也能明白为什么你尝试修改某些属性会失败。3.3 那些年我踩过的读取坑异步性与时机问题这是最经典的坑。属性值的改变和读取是实时的但属性的设置者和读取者的运行时机需要你仔细考量。回到开头的例子为什么在Activity.onCreate里读sys.boot_completed可能读不到1因为你的应用进程可能启动得比SurfaceFlinger设置这个属性更早。正确的做法是监听属性变化而不是一次性读取。在 Native 层可以使用property_listener在 Java 层可以监听SystemProperties的变化虽然这部分 API 是隐藏的但可以通过反射或在高权限系统进程中直接使用。权限不足导致的静默失败这不是property_get的典型问题因为读取权限通常很宽松。但极端情况下如果属性文件的 SELinux 标签或文件权限被配置为仅限特定uid/gid读取那么普通应用调用property_get可能返回空或默认值而不会抛出明确的权限错误。这在自定义属性时需要注意。属性名拼写错误与大小写属性名是大小写敏感的并且通常都是小写。ro.build.version.SDK和ro.build.version.sdk是两个不同的属性后者才是正确的。一个实用的调试技巧是先在adb shell中运行getprop | grep -i keyword来确认属性的确切名称和当前值。4. property_set 详解修改的权限迷宫如果说property_get是参观那么property_set就是装修需要许可证。失败是常态成功是特例。4.1 基础设置与返回值检查永远不要假设property_set会成功。你的代码必须检查返回值。int result property_set(my.debug.flag, 1); if (result 0) { LOGI(Property set successfully.); } else { LOGE(Failed to set property. errno: %d (%s), errno, strerror(errno)); // 常见的 errno 有 EPERM (权限不足)、EINVAL (无效参数如值过长) }4.2 深入权限控制SELinux 是终极守门员为什么property_set会失败核心原因在于权限控制。Android 的权限控制是双层的传统 Unix 文件权限属性在内存中映射到/dev/__properties__这个伪文件系统。每个属性文件都有对应的uid、gid和权限位如0660。init进程在创建属性时会根据预定义的规则在system/sepolicy/private/property_contexts等文件中设置这些元数据。例如一个属性可能只允许root用户或system用户组写入。SELinux这是更强大、更精细的现代安全模块。即使你的进程以root身份运行如果 SELinux 策略不允许你依然无法修改属性。SELinux 规则定义了域Domain如init、system_server、untrusted_app对类型Type属性文件的 SELinux 标签如property_type的访问权限setattr。例如一个典型的 SELinux 拒绝日志 (dmesg | grep avc) 可能如下avc: denied { set } for pid1234 commmy_app namesecure.system.property devproperties ino5678 scontextu:r:untrusted_app:s0 tcontextu:object_r:system_prop:s0 tclassproperty_service permissive0这条日志解读为来自untrusted_app域的进程my_app被拒绝了对标签为system_prop的属性执行set操作。如何知道一个属性需要什么权限查看property_contexts文件在 AOSP 源码的system/sepolicy/目录下有property_contexts文件。它使用正则表达式将属性名映射到 SELinux 类型。# system/sepolicy/private/property_contexts sys\.boot_completed u:object_r:system_prop:s0 debug\.\S u:object_r:debug_prop:s0 persist\.security\. u:object_r:security_prop:s0上面规则意味着sys.boot_completed的类型是system_prop所有以debug.开头的属性类型是debug_prop。查看 SELinux 策略文件接着在te文件中查找哪些域被允许set_prop或write到这些类型。# system/sepolicy/private/system_server.te allow system_server system_prop:property_service set; # system/sepolicy/private/untrusted_app.te # 通常没有 allow 规则所以普通应用无法设置 system_prop 类型的属性。4.3 实战如何在系统应用中安全地设置属性假设你正在开发一个系统签名应用platform证书需要设置一个自定义的调试属性debug.myapp.level。步骤一定义属性并分配类型你需要修改 SELinux 策略。这通常意味着你需要编译自定义 ROM 或拥有修改vendor分区的权限。在设备对应的 SELinux 策略目录如device/xxx/sepolicy/下找到或创建property_contexts文件。添加一行debug\.myapp\. u:object_r:myapp_debug_prop:s0。这表示所有以debug.myapp.开头的属性都属于myapp_debug_prop类型。步骤二为你的进程域授权找到你的应用进程的 SELinux 域。系统签名应用通常运行在system_app域或自定义域。在对应的.te文件如system_app.te或自定义的myapp.te中添加允许规则# 允许 system_app 域对 myapp_debug_prop 类型的属性执行 set 操作 allow system_app myapp_debug_prop:property_service set;如果你希望所有system组的进程都能设置也可以授权给system属性allow system myapp_debug_prop:property_service set;步骤三在代码中设置确保你的应用拥有android.permission.WRITE_SECURE_SETTINGS权限在AndroidManifest.xml中声明并且是系统应用。然后就可以安全地调用// Java 层 import android.os.SystemProperties; try { SystemProperties.set(debug.myapp.level, verbose); } catch (Exception e) { // 即使有权限也可能因为属性名无效等原因失败 Log.e(TAG, Set property failed, e); }或者在 Native 层调用property_set。重要提示修改 SELinux 策略是系统级操作错误配置可能导致设备无法启动陷入 SELinux 拒绝循环。务必在测试设备或模拟器上先行验证并充分理解 SELinux 规则语法。5. 高级话题属性变更监听与 init 触发器属性的威力不仅在于存储更在于其“变化”能触发行动。5.1 在 Native 代码中监听属性变化Android 提供了property_listener接口。你需要实现一个回调函数并在初始化时注册它。#include sys/system_properties.h static void my_property_callback(void) { // 当任何属性发生变化时这个回调都会被调用。 // 但你需要自己检查具体是哪个属性变了。 char value[PROP_VALUE_MAX]; property_get(my.watched.property, value, ); LOGI(Property might have changed. Current value: %s, value); } void init_listener() { // 这是一个简化的示例。实际 AOSP 代码中可能需要使用 __system_property_add_callback // 并且需要注意回调的线程上下文它可能在某个后台线程被调用。 // 以下代码说明了概念具体实现需参考最新源码。 property_listener listener; listener.callback my_property_callback; // 注册监听器伪代码实际 API 可能不同 register_property_listener(listener); }更精细的监听监听特定属性通常需要自己实现轮询或利用更底层的__system_property_find和__system_property_read_callback机制复杂度较高。5.2 在 Init 脚本中使用属性触发器这是属性系统最强大的用法之一。在init.rc或*.rc脚本中你可以定义当某个属性满足特定条件时执行一系列命令。# 这是一个 init *.rc 文件的片段 on property:sys.boot_completed1 property:myapp.ready0 # 当系统启动完成但我的应用还未就绪时 start my_service setprop myapp.ready 1 on property:persist.sys.debug.wifi1 # 当持久化调试标志打开时设置一些内核参数 write /proc/sys/net/ipv4/tcp_keepalive_time 300init语言中的属性触发器非常灵活支持逻辑与/或、字符串匹配和正则表达式部分版本是构建动态系统启动和响应逻辑的基石。例如根据ro.debuggable的值决定是否开启adbd的 root 权限就是通过这种机制实现的。6. 属性系统的“灰色地带”与实用技巧“只读”属性ro.真的只读吗如前所述在init进程的早期阶段on early-init或on init是可以设置ro.属性的。一旦init切换到main阶段或之后再尝试设置就会失败。此外在bootloader或kernel命令行中通过androidboot.前缀传递的参数最终也会以ro.boot.属性出现这些属性在 kernel 启动后就是真正的只读了。属性值长度限制的规避92 字节的限制有时不够用。一种常见的模式是将长数据拆分成多个属性例如myapp.config.part1,myapp.config.part2。另一种更规范的做法是使用其他 IPC 机制如 Binder、Socket传递大数据而只用属性来传递一个“通知”或“键”。性能考量属性操作是同步的并且涉及进程间通信与init进程或属性服务通信。频繁地设置属性尤其是在循环中会对性能产生负面影响。对于需要高频更新的状态应考虑使用共享内存、Binder 等更适合的机制。调试利器watchprops在adb shell中除了getprop/setprop还有一个不太为人知的命令watchprops。它可以实时监控所有属性的变化对于调试属性触发逻辑或理解系统内部状态流转非常有帮助。属性覆盖顺序当多个来源定义了同名属性时优先级顺序是kernel cmdline(如androidboot.serialnoXYZ) ro.* property files(如/vendor/build.prop) 运行时通过property_set设置的值。了解这一点有助于诊断属性值不符合预期的原因。理解property_get和property_set不仅仅是学会两个 API 的调用。它是你打开 Android 系统动态配置、状态管理和轻量级 IPC 大门的一把钥匙。从简单的版本读取到复杂的服务启动协调再到深度的系统定制这套机制无处不在。下次当你再使用getprop或SystemProperties.get时希望你能对背后那套精巧而强大的系统有更深的认识。在实际项目中尤其是在涉及系统修改或深度集成时务必画清属性操作的权限边界善用监听机制才能写出既强大又稳健的代码。

相关新闻

易基因:Cell Commun Signal/IF11.6:中国多家科研单位联合开发MEE表观遗传编辑器实现线粒体DNA甲基化精准调控 助力衰老机制研究

易基因:Cell Commun Signal/IF11.6:中国多家科研单位联合开发MEE表观遗传编辑器实现线粒体DNA甲基化精准调控 助力衰老机制研究

大家好,这里是专注表观组学十余年,领跑多组学科研服务的易基因。2026年6月27日,湘湖实验室(农业浙江省实验室)李果研究员/顾志敏研究员、重庆妇幼医院江雯研究员、浙江大学姚远研究员团队合作在《Cell Communication and Signaling》期刊发表…

2026/10/16 10:02:16 阅读更多 →
Verilog仿真与调试实战:从语法陷阱到跨时钟域处理

Verilog仿真与调试实战:从语法陷阱到跨时钟域处理

1. 从“Hello, World!”到“为什么我的仿真卡住了?”如果你刚开始接触Verilog,或者已经用它写过几个模块,那么你大概率已经体会过这种感受:代码在编辑器里看起来一切正常,语法高亮赏心悦目,但一跑仿真&…

2026/10/1 3:09:01 阅读更多 →
国际象棋AI性能优化:从Alpha-Beta剪枝到C#微观优化的完整指南

国际象棋AI性能优化:从Alpha-Beta剪枝到C#微观优化的完整指南

1. 项目概述:为什么你的国际象棋AI总是“慢半拍”? 如果你正在为 Chess-Challenge 项目编写一个国际象棋AI,并且发现它思考一步棋的时间长得让人失去耐心,甚至在对局中因超时而判负,那么你遇到的核心问题,几…

2026/10/16 7:25:44 阅读更多 →

最新新闻

Nero刻录光盘软件:从数据归档翻车到批量刻录实战

Nero刻录光盘软件:从数据归档翻车到批量刻录实战

简介:这是一份面向需要光盘刻录与影音处理用户的Nero刻录软件安装包,适合初次接触刻录工具或希望快速搭建本地刻录环境的人群使用,可解决系统重装后缺少刻录软件、日常备份数据与制作影音光盘等需求。压缩包为zip格式,共收录140个…

2026/10/16 16:45:24 阅读更多 →
CTF Misc 1分题逆向思维训练:文件结构与编码陷阱解析

CTF Misc 1分题逆向思维训练:文件结构与编码陷阱解析

1. 这不是刷题清单,而是一份“逆向思维训练手册”“攻防世界 Misc高手进阶区 1分题 全解”——看到这个标题,很多人第一反应是:又一份答案速查表?点开就抄flag?但我在某高校网络安全实训实验室带过三届学员&#xff0c…

2026/10/16 16:45:24 阅读更多 →
Qt5 UDP局域网聊天源码解析:从QUdpSocket到双机互通实战

Qt5 UDP局域网聊天源码解析:从QUdpSocket到双机互通实战

简介:这是一份面向Qt初学者与网络编程入门者的UDP局域网通信示例工程,围绕Qt5中QUdpSocket的封装与使用展开,帮助读者理解无连接协议下客户端如何通过绑定同一IP的不同端口实现消息收发。资源包共8个文件,包含3个cpp源文件、2个h头…

2026/10/16 16:45:24 阅读更多 →
SmartAgent:基于用户认知链的具身化智能体架构

SmartAgent:基于用户认知链的具身化智能体架构

1. 这不是又一个“智能体”概念炒作,而是用户意图建模的实质性突破最近在几个技术社区和内部项目复盘会上,反复看到SmartAgent这个词被提起,但多数讨论停留在“它用了Chain-of-Thought”这种表面归因。我带团队落地过3个面向终端用户的个性化…

2026/10/16 16:45:24 阅读更多 →
Spring Boot整合SSM开发烘焙分享网站:毕业设计全流程拆解

Spring Boot整合SSM开发烘焙分享网站:毕业设计全流程拆解

1. 项目概述 1.1 核心需求解析 先说结论: springboot_ssm823烘焙美食爱好者分享网站 这个标题,一眼就能看出是典型的Java Web方向毕业设计或课程设计选题。项目编号后缀是常见选题列表里的排序,不用特别在意。技术组合为Spring Boot SSM&…

2026/10/16 16:45:24 阅读更多 →
Git报错unmerged files详解:分支切换被拦时如何解决冲突并成功checkout

Git报错unmerged files详解:分支切换被拦时如何解决冲突并成功checkout

我遇到过太多次这个报错了,尤其是在几个分支之间来回切换、或者从远程拉取代码的时候。刚接触 Git 的人看到 “Can‘t checkout because of unmerged files” 这行字,第一反应往往是“我是不是把仓库搞坏了”,其实远没有这么严重。这个报错本…

2026/10/16 16:44:22 阅读更多 →

日新闻

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

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

2026/10/16 0:00:05 阅读更多 →
信号与系统频域分析作业解题指南

信号与系统频域分析作业解题指南

1. 作业背景与核心要求解析信号与系统作为电子信息类专业的核心基础课,其作业设计往往直接关联课程的核心知识框架。2026年春季第六次作业的布置,通常对应着课程进展到频域分析的关键阶段。根据常规教学进度推测,本次作业可能涉及以下核心内容…

2026/10/16 0:00:05 阅读更多 →
Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

1. 从一个"四不像"的练手项目说起如果你已经看完了Django的官方教程前几章,跟着敲完了投票应用,大概会有一种"我好像会了,又好像什么都不会"的割裂感。教程里每个知识点都是拆开的——这一章讲模型,下一章讲视…

2026/10/16 0:00:05 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/15 19:41:51 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/16 14:23:36 阅读更多 →