Android自定义View开发全解析:从基础到高级实践
1. Android自定义View开发全景指南在移动应用开发领域Android自定义View一直是区分普通开发者与高级开发者的分水岭。我至今记得第一次成功实现自定义View时的兴奋感——那种完全掌控界面元素的感觉就像画家第一次在空白画布上自由创作。自定义View不仅是技术能力的体现更是解决复杂UI需求的终极武器。从简单的扩展现有控件到构建完整的自定义布局容器自定义View技术贯穿了Android开发的各个层级。本文将带你从基础概念出发逐步深入到onMeasure测量、onDraw绘制、手势交互等核心实现最终完成自定义布局容器的完整开发闭环。无论你是想实现独特的进度条、打造专属图表库还是创建完全自定义的交互控件这套方法论都能为你提供系统性的指导。2. 自定义View基础架构与类型选择2.1 自定义View的三种实现方式在实际项目中我们通常根据需求复杂度选择不同的实现路径继承现有控件扩展控件适用场景保留原生控件大部分特性仅需修改部分外观或行为典型案例圆角ImageView、带文字提示的ProgressBar优势开发成本低可复用原生控件所有功能示例代码框架public class CustomTextView extends TextView { // 只需重写需要修改的方法 Override protected void onDraw(Canvas canvas) { // 自定义绘制逻辑 super.onDraw(canvas); // 保留父类绘制 } }组合现有控件组合控件适用场景由多个现有控件组合而成的复合功能单元典型案例带删除按钮的输入框、底部导航栏组件实现要点继承ViewGroup如RelativeLayout在构造函数中inflate布局文件提供统一的API管理子控件内存优化技巧使用Merge标签减少布局层级完全自定义View继承View适用场景需要全新绘制逻辑和交互方式的特殊控件典型案例股票走势图、游戏手柄控件核心方法onMeasure确定View尺寸onDraw执行自定义绘制onTouchEvent处理触摸交互2.2 选择策略与性能考量在项目实践中我总结出一个简单的决策流程检查是否可以通过简单样式调整实现需求使用XML考虑是否可以通过组合现有控件满足功能组合控件评估是否需要完全自定义绘制逻辑继承View对于频繁更新的动态元素优先考虑SurfaceView重要提示在Android 5.0设备上自定义View应默认开启硬件加速android:hardwareAcceleratedtrue可显著提升绘制性能。但在使用特殊PorterDuff混合模式时可能需要临时关闭。3. 核心绘制流程深度解析3.1 测量阶段onMeasure实战测量过程是自定义View的基石错误的测量会导致布局紊乱甚至崩溃。理解MeasureSpec是掌握onMeasure的关键Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 解析测量规格 int widthMode MeasureSpec.getMode(widthMeasureSpec); int widthSize MeasureSpec.getSize(widthMeasureSpec); // 计算期望尺寸包含内边距 int desiredWidth getPaddingLeft() getPaddingRight() calculateContentWidth(); // 根据测量模式确定最终宽度 int finalWidth; switch (widthMode) { case MeasureSpec.EXACTLY: finalWidth widthSize; break; case MeasureSpec.AT_MOST: finalWidth Math.min(desiredWidth, widthSize); break; case MeasureSpec.UNSPECIFIED: default: finalWidth desiredWidth; } // 高度计算同理... // 必须调用setMeasuredDimension setMeasuredDimension(finalWidth, finalHeight); }常见测量误区及解决方案忘记处理padding导致内容与边界重叠正确做法所有尺寸计算必须包含getPaddingXXX()忽略父容器的MeasureSpec可能导致显示不全或过度留白应对策略正确处理AT_MOST和EXACTLY模式多次测量不一致触发无限测量循环调试技巧在requestLayout()前添加条件判断3.2 绘制阶段onDraw高级技巧Canvas和Paint是自定义绘制的两大核心工具。以下是我在多年实践中总结的高效绘制方案1. 对象复用优化// 类成员变量避免频繁创建对象 private Paint mPaint new Paint(Paint.ANTI_ALIAS_FLAG); private Path mPath new Path(); private RectF mTempRect new RectF(); Override protected void onDraw(Canvas canvas) { // 复用预创建的对象 mPaint.setColor(Color.RED); mPath.reset(); // 绘制逻辑... }2. 分层绘制策略将静态元素与动态元素分离绘制减少无效重绘// 使用bitmap缓存静态背景 private Bitmap mCacheBitmap; private void drawStaticContent(Canvas canvas) { if (mCacheBitmap null) { mCacheBitmap Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); Canvas cacheCanvas new Canvas(mCacheBitmap); // 绘制静态背景到cacheCanvas } canvas.drawBitmap(mCacheBitmap, 0, 0, null); } Override protected void onDraw(Canvas canvas) { drawStaticContent(canvas); // 只绘制动态内容 drawDynamicContent(canvas); }3. 高级绘制技术使用Shader实现渐变效果mPaint.setShader(new LinearGradient( 0, 0, getWidth(), 0, Color.RED, Color.BLUE, Shader.TileMode.CLAMP));路径绘制优化避免锯齿mPaint.setAntiAlias(true); mPaint.setDither(true); // 增强颜色过渡4. 手势交互与事件处理4.1 触摸事件处理精要自定义View的交互能力直接影响用户体验。以下是一个支持缩放和拖拽的典型实现private ScaleGestureDetector mScaleDetector; private GestureDetector mGestureDetector; private float mPosX, mPosY; private float mScaleFactor 1.f; public CustomView(Context context) { super(context); mScaleDetector new ScaleGestureDetector(context, new ScaleListener()); mGestureDetector new GestureDetector(context, new GestureListener()); } Override public boolean onTouchEvent(MotionEvent event) { mScaleDetector.onTouchEvent(event); mGestureDetector.onTouchEvent(event); return true; } private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { Override public boolean onScale(ScaleGestureDetector detector) { mScaleFactor * detector.getScaleFactor(); mScaleFactor Math.max(0.1f, Math.min(mScaleFactor, 5.0f)); invalidate(); return true; } } private class GestureListener extends GestureDetector.SimpleOnGestureListener { Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { mPosX - distanceX; mPosY - distanceY; invalidate(); return true; } } Override protected void onDraw(Canvas canvas) { canvas.save(); canvas.translate(mPosX, mPosY); canvas.scale(mScaleFactor, mScaleFactor); // 绘制内容... canvas.restore(); }4.2 复杂手势处理模式对于需要处理多点触控的高级场景推荐使用以下架构状态机模式定义VIEW_STATE_IDLE、VIEW_STATE_DRAGGING等状态速度追踪器计算滑动速度实现惯性滚动VelocityTracker mVelocityTracker VelocityTracker.obtain(); mVelocityTracker.addMovement(event); mVelocityTracker.computeCurrentVelocity(1000); float xVelocity mVelocityTracker.getXVelocity();冲突解决策略外部拦截法父容器onInterceptTouchEvent内部拦截法子View requestDisallowInterceptTouchEvent5. 自定义布局容器实现5.1 布局流程核心方法实现自定义ViewGroup需要深入理解以下方法Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 1. 测量所有子View measureChildren(widthMeasureSpec, heightMeasureSpec); // 2. 计算自身尺寸考虑子View尺寸和margin int maxChildWidth 0; for (int i 0; i getChildCount(); i) { View child getChildAt(i); maxChildWidth Math.max(maxChildWidth, child.getMeasuredWidth() getMargin(child)); } // 3. 考虑自身padding int finalWidth getPaddingLeft() getPaddingRight() maxChildWidth; // 4. 处理MeasureSpec finalWidth resolveSize(finalWidth, widthMeasureSpec); // 高度计算同理... setMeasuredDimension(finalWidth, finalHeight); } Override protected void onLayout(boolean changed, int l, int t, int r, int b) { // 遍历所有子View进行布局 int currentTop getPaddingTop(); for (int i 0; i getChildCount(); i) { View child getChildAt(i); // 获取子View测量尺寸 int childWidth child.getMeasuredWidth(); int childHeight child.getMeasuredHeight(); // 计算子View位置考虑margin MarginLayoutParams lp (MarginLayoutParams) child.getLayoutParams(); int childLeft getPaddingLeft() lp.leftMargin; int childTop currentTop lp.topMargin; // 布局子View child.layout(childLeft, childTop, childLeft childWidth, childTop childHeight); // 更新下一个子View的起始位置 currentTop childHeight lp.topMargin lp.bottomMargin; } }5.2 自定义LayoutParams对于需要支持特殊布局参数的容器需要实现自定义LayoutParamspublic static class LayoutParams extends MarginLayoutParams { public int gravity Gravity.TOP | Gravity.START; public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); // 解析自定义属性 TypedArray a c.obtainStyledAttributes(attrs, R.styleable.CustomLayout_Layout); gravity a.getInt(R.styleable.CustomLayout_Layout_layout_gravity, gravity); a.recycle(); } } // 必须重写以下方法 Override protected LayoutParams generateDefaultLayoutParams() { return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); } Override protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { return new LayoutParams(p); } Override public LayoutParams generateLayoutParams(AttributeSet attrs) { return new LayoutParams(getContext(), attrs); }6. 性能优化与调试技巧6.1 渲染性能优化过度绘制检测开启开发者选项中的显示过度绘制目标控制大多数区域在2x过度绘制以内View层级优化使用ViewGroup.isChildrenDrawingOrderEnabled控制绘制顺序对于复杂静态背景使用setClipToPadding和setClipChildren内存优化策略大尺寸Bitmap使用inSampleSize加载离屏缓冲使用RGB_565格式当不需要alpha通道时6.2 调试工具链Layout Inspector实时查看View树结构检查测量尺寸与实际布局尺寸GPU渲染分析识别绘制瓶颈优化onDraw方法中的耗时操作自定义调试工具// 在onDraw中添加调试绘制 if (DEBUG_MODE) { mPaint.setColor(Color.RED); mPaint.setStyle(Paint.Style.STROKE); canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint); }7. 实战案例实现一个环形进度条综合应用前述技术我们实现一个支持渐变色和动画的环形进度条public class CircleProgressView extends View { private Paint mRingPaint new Paint(Paint.ANTI_ALIAS_FLAG); private Paint mBgPaint new Paint(Paint.ANTI_ALIAS_FLAG); private RectF mOval new RectF(); private float mProgress; private ValueAnimator mAnimator; public CircleProgressView(Context context) { super(context); init(); } private void init() { mRingPaint.setStyle(Paint.Style.STROKE); mRingPaint.setStrokeWidth(dpToPx(8)); mRingPaint.setStrokeCap(Paint.Cap.ROUND); mBgPaint.setStyle(Paint.Style.STROKE); mBgPaint.setStrokeWidth(dpToPx(8)); mBgPaint.setColor(Color.LTGRAY); // 初始化动画 mAnimator ValueAnimator.ofFloat(0, 1); mAnimator.setDuration(1000); mAnimator.addUpdateListener(animation - { mProgress (float) animation.getAnimatedValue(); invalidate(); }); } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); float radius Math.min(w, h) * 0.4f; float centerX w / 2f; float centerY h / 2f; float strokeWidth dpToPx(8); mOval.set(centerX - radius, centerY - radius, centerX radius, centerY radius); // 更新渐变着色器 mRingPaint.setShader(new SweepGradient(centerX, centerY, new int[]{Color.RED, Color.YELLOW, Color.GREEN}, null)); } Override protected void onDraw(Canvas canvas) { // 绘制背景环 canvas.drawArc(mOval, 0, 360, false, mBgPaint); // 绘制进度环 canvas.drawArc(mOval, -90, 360 * mProgress, false, mRingPaint); } public void setProgress(float progress, boolean animate) { if (animate) { mAnimator.setFloatValues(mProgress, progress); mAnimator.start(); } else { mProgress progress; invalidate(); } } private float dpToPx(float dp) { return dp * getResources().getDisplayMetrics().density; } }这个实现包含了自定义View的多个关键技术点正确处理View尺寸变化onSizeChanged使用Shader实现渐变色效果属性动画驱动进度变化抗锯齿和圆角线帽优化视觉效果密度无关的尺寸转换dpToPx8. 进阶主题与现代Android架构的整合8.1 与Compose的互操作在Jetpack Compose项目中复用传统自定义ViewComposable fun CustomViewWrapper() { AndroidView( factory { context - MyCustomView(context).apply { setBackgroundColor(Color.WHITE) } }, update { view - // 响应状态变化 view.updateParams(newParams) } ) }8.2 视图数据绑定优化使用ViewDataBinding减少findViewById调用layout data variable nameviewModel typecom.example.CustomViewModel/ /data com.example.CustomView android:layout_widthmatch_parent android:layout_heightmatch_parent app:progress{viewModel.progress} app:maxValue{viewModel.max}/ /layout在自定义View中实现数据绑定支持BindingAdapter({progress, maxValue}) public static void setProgress(CustomView view, float progress, float max) { view.setMax(max); view.setProgress(progress); }9. 版本兼容性处理9.1 多版本适配策略API级别检查if (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { // 使用新API setElevation(dpToPx(4)); } else { // 回退方案 setBackgroundDrawable(createShadowDrawable()); }属性兼容处理在res/values-v21/中定义不同版本的样式属性RTL布局支持if (Build.VERSION.SDK_INT Build.VERSION_CODES.JELLY_BEAN_MR1) { setLayoutDirection(LAYOUT_DIRECTION_LTR); }9.2 暗黑模式适配颜色资源使用?attr引用color nametext_color?android:attr/textColorPrimary/color在代码中检测当前模式int nightModeFlags getResources().getConfiguration().uiMode Configuration.UI_MODE_NIGHT_MASK; boolean isNightMode nightModeFlags Configuration.UI_MODE_NIGHT_YES;10. 测试与质量保障10.1 单元测试策略测量逻辑测试Test public void testMeasureSpecHandling() { CustomView view new CustomView(mContext); int widthSpec View.MeasureSpec.makeMeasureSpec(200, View.MeasureSpec.EXACTLY); int heightSpec View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.AT_MOST); view.measure(widthSpec, heightSpec); assertEquals(200, view.getMeasuredWidth()); assertTrue(view.getMeasuredHeight() 300); }绘制内容验证使用MockCanvas验证绘制调用Test public void verifyDrawingOperations() { CustomView view new CustomView(mContext); TestCanvas canvas new TestCanvas(); view.draw(canvas); assertTrue(canvas.containsDrawCircle()); assertEquals(5, canvas.getPathCount()); }10.2 UI自动化测试使用Espresso进行交互测试Test public void testTouchInteraction() { onView(withId(R.id.custom_view)) .perform(swipeLeft()) .check(matches(withProgress(0.5f))); }在自定义View中实现测试辅助方法public float getCurrentProgress() { return mProgress; } public void setTestMode(boolean enabled) { mTestMode enabled; if (enabled) { setAnimationDuration(0); // 测试时禁用动画 } }

相关新闻

【OpenClaw从入门到精通】第85篇:OpenClaw 高阶实战之环境搭建:从零开始部署 OpenClaw 集群(万字实战详解)

【OpenClaw从入门到精通】第85篇:OpenClaw 高阶实战之环境搭建:从零开始部署 OpenClaw 集群(万字实战详解)

【OpenClaw从入门到精通】第85篇:OpenClaw 高阶实战之环境搭建:从零开始部署 OpenClaw 集群(万字实战详解) 摘要 当你的 AI Agent 从单机原型迈向生产环境,一定会撞上并发瓶颈、状态持久化、模型切换三大难题。OpenClaw 作为一个开源的分布式 AI Agent 编排平台,专为解…

2026/10/10 9:15:47 阅读更多 →
基于Wan2.2和ComfyUI的AI视频转场技术解析

基于Wan2.2和ComfyUI的AI视频转场技术解析

1. 项目概述:基于Wan2.2的ComfyUI视频转场工作流最近在测试一套基于ComfyUI的Wan2.2 14B模型视频生成方案,特别适合需要将首尾两帧图像转化为动态视频的场景。这个工作流最吸引我的地方在于,它通过高低噪声模型的组合控制,配合LoR…

2026/10/8 6:19:03 阅读更多 →
AI论文降重工具实测与学术写作优化指南

AI论文降重工具实测与学术写作优化指南

1. 论文降重困境与AI检测现状去年帮学弟修改毕业论文时,他的初稿被导师打回并标注"AI生成痕迹过重"。查重平台显示AI率高达68%,远超学校规定的5%红线。这种情况在ChatGPT等工具普及后越来越常见——很多同学只是用AI辅助构思框架或润色语句&am…

2026/10/1 4:19:45 阅读更多 →

最新新闻

Keil C51 9.61安装配置与LED点灯:51单片机入门完整教程

Keil C51 9.61安装配置与LED点灯:51单片机入门完整教程

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

2026/10/11 3:07:23 阅读更多 →
MySQL逻辑架构详解:一条SQL的完整旅行路线

MySQL逻辑架构详解:一条SQL的完整旅行路线

聊到数据库原理,很多同学第一反应是先去啃索引结构、事务隔离级别这些硬核知识点,但我带过不少新人之后发现,先把MySQL的逻辑架构吃透,后面看什么问题都顺。所谓逻辑架构,说白了就是一条SQL从客户端出发,到…

2026/10/11 3:07:23 阅读更多 →
云智变AI官网课程论文撰写功能实测:当AI不再“贴皮”,而是替你“搭骨架”

云智变AI官网课程论文撰写功能实测:当AI不再“贴皮”,而是替你“搭骨架”

先说一个反常识的判断 你在网上搜“AI写课程论文”,看到的大多数推荐,其实回答的是另一个问题——“哪个AI聊天机器人好用”。把摘要丢给ChatGPT,说“帮我扩写成三千字”,它确实能写出来,读起来也挺像那么回事。但交给…

2026/10/11 3:07:23 阅读更多 →
MySQL内置函数实战指南:从字符串到窗口函数避开性能坑

MySQL内置函数实战指南:从字符串到窗口函数避开性能坑

干我们这行的,写SQL就像写字一样,MySQL内置函数就是最常用的那套笔画。别小看这几十个函数,用得好,原来要写十几行业务逻辑的查询,一行就能解决;用得不好,线上慢查询一抓一大把,报表…

2026/10/11 3:07:23 阅读更多 →
数据库B+树实现指南:从页面结构到并发控制

数据库B+树实现指南:从页面结构到并发控制

这个项目我当年做的时候连续肝了几天,中间被并发和边界条件来回折磨,最后跑通所有测试那一刻还是很有成就感的。准备这期内容的时候,我又把之前踩过的坑和从代码层面梳理过的思路重新过了一遍,结合常见的作业版本整理成一篇适合照…

2026/10/11 3:07:23 阅读更多 →
数据可用性与代码仓库声明也要双版本烟测:三列表 + A/B 验收表

数据可用性与代码仓库声明也要双版本烟测:三列表 + A/B 验收表

千笔-AIWritePaper https://www.aiwritepaper.com 「数据可应要求提供」「代码见附件」在抽查时经常落空。本文用三列表把声明钉到可打开的产物,并做 A/B 与伪 B 烟测。示例全部使用本地假仓库路径,不放公网 URL(_w/data-avail-ab/fake_repo…

2026/10/11 3:06:23 阅读更多 →

日新闻

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

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

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

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

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

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

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

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

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

2026/10/11 0:00:27 阅读更多 →

周新闻

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

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

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

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

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

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

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

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

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

2026/10/11 0:00:27 阅读更多 →

月新闻

我发现了一个新思路:用 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/10 5:23:50 阅读更多 →
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/9 21:32:20 阅读更多 →
黑夜航拍船只数据集训练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/10 10:38:42 阅读更多 →