news 2026/9/16 7:18:43

Android Media3音乐播放器2.0:适配Android 12+的后台播放与Scoped Storage实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android Media3音乐播放器2.0:适配Android 12+的后台播放与Scoped Storage实战

简介:本资源是面向Android初学者的音乐播放器开发实战项目,聚焦移动应用开发核心技能训练,特别适合课程设计、大作业及自学实践。作为1.0版本的深度升级版,2.0版本新增上一首/下一首功能、采用个性化按钮UI设计,并为全部关键逻辑补充详尽中文注释,显著提升代码可读性与学习效率。压缩包共474个文件,涵盖156个flat资源文件(含界面布局与素材)、118个json配置、28个png图标、22个xml布局与权限声明、8个java源码文件及7个mp3示例音频,整体达275.02MB,结构完整、模块清晰,便于逐层理解Activity生命周期、MediaPlayer控制逻辑与UI事件响应机制。目前已有5247人学习下载,提供开箱即用的可运行工程,含Gradle构建脚本、调试APK及完整资源目录,助新手快速掌握Android Studio开发全流程。

1. 为什么一个“Android Studio 音乐播放器 2.0”值得重做一遍?

不是所有音乐播放器 Demo 都叫 2.0 —— 它不是版本号堆砌,而是对 Android 原生音频链路一次系统性补全。很多初学者用 MediaPlayer 写完基础播放后卡在:进度条拖动失灵、后台播放被系统杀掉、横竖屏切换时状态丢失、点播多个文件后内存暴涨、甚至连本地 MP3 文件路径都读不到(尤其 Android 10+ Scoped Storage 下)。这些不是“小问题”,而是 Android 音频开发中真实存在的断层:API 演进没跟上文档更新,权限模型变了但示例代码还停留在READ_EXTERNAL_STORAGEService被弃用却没人讲清楚ForegroundService怎么配 Notification Channel,MediaSession的回调逻辑和 UI 同步机制也常被一笔带过。本篇聚焦「可运行、可调试、可上线」的最小闭环:用 Android Studio Flamingo(2022.2.1)及以上 + AGP 8.1+ +androidx.media3:media3-session:1.2.1构建一个真正能响应系统媒体键、支持后台持续播放、适配 Android 12~14 的音乐播放器。它不依赖任何第三方 SDK,所有逻辑直连 Android 原生音频栈,小白照着敲能跑通,老手能从中拎出AudioFocus处理边界、ExoPlayer自定义LoadControlMediaSessionConnector状态同步等关键路径。


2. 从零搭建可后台播放的 Media3 播放核心:替代已废弃的 MediaPlayer

2.1 为什么必须放弃 MediaPlayer,转向 Media3?

MediaPlayer在 Android 12(API 31)起被标记为@Deprecated,其根本缺陷在于:无法与系统媒体会话(MediaSession)深度集成,不支持动态音轨切换(如 AAC/FLAC 自适应)、无内置缓冲策略控制、且setDataSource()对 Content URI 支持极差。而androidx.media3是 Google 官方推荐的下一代媒体库,核心组件ExoPlayer提供细粒度生命周期控制、自定义解码器链、网络带宽自适应(ABR),更重要的是MediaSessionMediaSessionService已深度耦合,能原生响应蓝牙耳机按键、系统通知栏控制、语音助手指令。项目升级到 2.0 的第一要义,就是将播放引擎从MediaPlayer迁移至ExoPlayer,并用MediaSessionService替代传统Service

2.2 在 Android Studio 中配置 Media3 依赖与权限

在模块级build.gradle(注意是app/build.gradle,非项目根目录)中添加以下依赖(使用implementation,勿用api):

// app/build.gradle android { namespace "com.example.musicplayer" compileSdk 34 defaultConfig { applicationId "com.example.musicplayer" minSdk 21 targetSdk 34 // 必须 ≥31 才能使用 MediaSessionService versionCode 20 versionName "2.0" } } dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.10.0' // Media3 核心依赖(2024 年主流稳定版) implementation 'androidx.media3:media3-common:1.2.1' implementation 'androidx.media3:media3-exoplayer:1.2.1' implementation 'androidx.media3:media3-session:1.2.1' implementation 'androidx.media3:media3-ui:1.2.1' // 提供 MediaControllerWidget 等 UI 组件 // Kotlin 协程支持(避免主线程阻塞) implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' }

提示media3-session是 2.0 版本的核心差异点——它让播放器不再是孤立 App,而是系统媒体生态中的“一员”。若跳过此依赖,将无法在锁屏界面显示控制按钮,也无法通过adb shell cmd media_session查看会话状态。

同时,在AndroidManifest.xml中声明必要权限与服务:

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <!-- Android 13+ 替代 READ_EXTERNAL_STORAGE --> <application ...> <!-- 媒体会话服务:必须声明 android:exported="true" 且含 SERVICE 类别 --> <service android:name=".service.MusicService" android:exported="true" android:foregroundServiceType="mediaPlayback"> <intent-filter> <action android:name="androidx.media3.session.MediaSessionService" /> </intent-filter> </service> <!-- 前台服务所需的通知渠道(Android 8.0+ 强制) --> <meta-data android:name="android.app.default_notification_channel_id" android:value="music_playback" /> </application>

2.3 实现 MediaSessionService:让播放器“活”在系统里

创建MusicService.kt,继承MediaSessionService(非Service):

// app/src/main/java/com/example/musicplayer/service/MusicService.kt class MusicService : MediaSessionService() { private lateinit var mediaSession: MediaSession private lateinit var player: ExoPlayer override fun onCreate() { super.onCreate() // 1. 初始化 ExoPlayer(启用硬件解码、设置缓冲策略) player = ExoPlayer.Builder(this) .setTrackSelector(DefaultTrackSelector(this)) .setLoadControl( DefaultLoadControl.Builder() .setBufferDurationsMs( 25_000, // min buffer (25s) 50_000, // max buffer (50s) 2500, // playback after rebuffer (2.5s) 5000 // rebuffer delay (5s) ) .build() ) .build() // 2. 创建 MediaSession 并绑定 Player mediaSession = MediaSession.Builder(this, player) .setId("music_session") .setCallback(object : MediaSession.Callback() { override fun onPlay(session: MediaSession, mediaItem: MediaItem?, extras: Bundle?) { player.playWhenReady = true } override fun onPause(session: MediaSession) { player.playWhenReady = false } override fun onSkipToNext(session: MediaSession) { player.seekToNext() } override fun onSkipToPrevious(session: MediaSession) { player.seekToPrevious() } }) .build() // 3. 设置前台服务通知(必须在 startForeground() 前调用) setSessionToken(mediaSession.token) } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { return mediaSession } override fun onDestroy() { mediaSession.release() player.release() super.onDestroy() } }

参数说明DefaultLoadControlbufferDurationsMs四个参数分别控制:最小缓冲时长(防卡顿)、最大缓冲时长(省内存)、播放恢复阈值(缓冲够了就播)、重新缓冲延迟(网络抖动时等待时间)。2.0 版本将默认值从10s/50s/2.5s/5s调整为更激进的25s/50s/2.5s/5s,兼顾流畅性与低延迟。若目标设备内存紧张,可将minBufferMs降至15_000

2.4 验证 MediaSession 是否生效:三步终端命令检测

在 Android Studio Terminal 中执行以下命令,确认会话已注册:

# 1. 查看当前活跃的媒体会话 adb shell cmd media_session list-sessions # 2. 查看指定会话详细信息(输出应包含 "state=PLAYING" 或 "state=PAUSED") adb shell cmd media_session session --package com.example.musicplayer --user 0 # 3. 发送播放指令(无需启动 App,直接测试服务响应) adb shell cmd media_session dispatch --package com.example.musicplayer play

list-sessions返回空,检查MusicService是否在AndroidManifest.xml中正确声明androidx.media3.session.MediaSessionServiceaction;若dispatch play无反应,确认onPlay()回调中是否设置了player.playWhenReady = true


3. 构建响应式 UI 层:用 MediaControllerWidget 同步播放状态

3.1 Activity 中初始化 MediaController 并绑定 Session

MainActivity.kt中,不再直接操作ExoPlayer,而是通过MediaController间接控制:

// app/src/main/java/com/example/musicplayer/MainActivity.kt class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var controller: MediaController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // 1. 连接 MediaSessionService(自动处理跨进程通信) val sessionToken = MediaSessionService.getSessionToken(this, MusicService::class.java) controller = MediaController.Builder(this, sessionToken).build() // 2. 将控制器绑定到 UI 组件(自动同步状态) binding.playButton.setOnClickListener { controller.play() } binding.pauseButton.setOnClickListener { controller.pause() } binding.nextButton.setOnClickListener { controller.skipToNext() } binding.prevButton.setOnClickListener { controller.skipToPrevious() } // 3. 监听播放状态变化(UI 更新入口) controller.addMediaSessionCallback(object : MediaController.Callback() { override fun onIsPlayingChanged(controller: MediaController) { updatePlayButtonState() } override fun onPlaybackState(controller: MediaController) { updateProgressAndPosition() } }) } private fun updatePlayButtonState() { binding.playButton.isEnabled = controller.isPlayable binding.playButton.text = if (controller.isPlaying) "⏸️ 暂停" else "▶️ 播放" } private fun updateProgressAndPosition() { val position = controller.playbackState?.position ?: 0L val duration = controller.playbackState?.duration ?: 0L binding.progressBar.max = duration.toInt() binding.progressBar.progress = position.toInt() } override fun onDestroy() { controller.release() super.onDestroy() } }

注意MediaController是跨进程代理对象,它不持有ExoPlayer实例,所有操作均通过 Binder 调用MusicService中的MediaSession。这意味着即使MainActivity被系统回收,只要MusicService在前台运行,播放就不会中断。

3.2 使用 MediaControllerWidget 实现进度条拖动

media3-ui提供PlayerControlViewProgressBar的增强版MediaControllerWidget,但需手动处理拖动事件:

<!-- res/layout/activity_main.xml --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.exoplayer2.ui.PlayerControlView android:id="@+id/playerView" android:layout_width="match_parent" android:layout_height="wrap_content" /> <!-- 自定义进度条(支持拖动) --> <SeekBar android:id="@+id/progressBar" android:layout_width="match_parent" android:layout_height="wrap_content" android:max="1000" /> </LinearLayout>

MainActivity.kt中添加拖动监听:

// 续写 onCreate() 中的初始化逻辑 binding.progressBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { if (fromUser && controller.playbackState?.duration != null) { val newPosition = (progress * controller.playbackState.duration) / 1000 controller.seekTo(newPosition) } } override fun onStartTrackingTouch(seekBar: SeekBar?) {} override fun onStopTrackingTouch(seekBar: SeekBar?) {} })

关键逻辑SeekBarmax设为1000是为避免浮点精度丢失,实际位置换算公式为(progress / 1000) × durationfromUser == true是防止controller主动更新进度时触发反向拖动。

3.3 处理 Scoped Storage:安全读取本地音频文件

Android 10+ 强制应用使用分区存储(Scoped Storage),不能直接访问Environment.getExternalStorageDirectory()。2.0 版本必须改用MediaStore查询音频:

// 在 MainActivity 中添加音频扫描方法 private fun loadLocalAudioFiles() { val projection = arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DATA // 仅用于兼容旧 API,新 API 推荐用 Content URI ) val cursor = contentResolver.query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Audio.Media.TITLE} ASC" ) cursor?.use { val idColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) val titleColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) val artistColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) val durationColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) while (it.moveToNext()) { val id = it.getLong(idColumn) val title = it.getString(titleColumn) ?: "未知标题" val artist = it.getString(artistColumn) ?: "未知艺术家" val duration = it.getLong(durationColumn) // 构建 MediaItem(关键:使用 Content URI,非 file:// 路径) val uri = ContentUris.withAppendedId( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id ) val mediaItem = MediaItem.Builder() .setUri(uri) .setMediaId("$id") .setMediaMetadata( MediaMetadata.Builder() .setTitle(title) .setArtist(artist) .setDurationMs(duration) .build() ) .build() // 添加到播放队列(此处简化为单曲播放,实际可用 player.setMediaItems(list)) controller.setMediaItem(mediaItem) } } }

参数说明ContentUris.withAppendedId()生成的 URI 形如content://media/external/audio/media/12345,系统自动授予临时读取权限,无需申请READ_MEDIA_AUDIO运行时权限(但需在 Manifest 中声明)。若目标 Android 版本 < 33,仍需动态申请READ_EXTERNAL_STORAGE


4. 解决 2.0 版本高频崩溃与卡顿:AudioFocus 与内存泄漏防护

4.1 正确申请 AudioFocus:避免被系统静音

当其他 App(如电话、导航)请求音频焦点时,若未正确处理AUDIOFOCUS_LOSS_TRANSIENT,会导致播放器无声且无提示。在MusicService.ktonCreate()后添加焦点监听:

// MusicService.kt 中 onCreate() 末尾追加 private fun initAudioFocus() { val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setOnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> { // 永久失去焦点:停止播放,释放资源 player.playWhenReady = false mediaSession.setActive(false) } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { // 短暂失去:暂停,等待恢复 player.playWhenReady = false } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { // 可降低音量继续播放(如通知音) player.volume = 0.2f } AudioManager.AUDIOFOCUS_GAIN -> { // 重新获得焦点:恢复播放与音量 player.playWhenReady = true player.volume = 1.0f } } } .build() audioManager.requestAudioFocus(focusRequest) }

注意AUDIOFOCUS_LOSS必须调用mediaSession.setActive(false),否则系统认为会话仍活跃,可能触发 ANR。AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK不应暂停播放,仅调低音量,这是 2.0 版本提升用户体验的关键细节。

4.2 防止 ExoPlayer 内存泄漏:Lifecycle-Aware 释放

ExoPlayer实例若在Activity销毁后未释放,会持有Context导致内存泄漏。MediaSessionService本身具备生命周期感知能力,但需确保playermediaSessiononDestroy()中彻底释放:

// MusicService.kt 中 onDestroy() 方法(已存在,此处强调关键点) override fun onDestroy() { // 必须按顺序释放:先释放 Session,再释放 Player mediaSession.release() // 此操作会自动解除与 player 的绑定 player.release() // 此操作会清空所有缓冲区和解码器 super.onDestroy() }

验证方法:在 Android Studio Profiler 中开启 Memory 分析,反复启动/关闭播放器,观察ExoPlayerImpl实例数是否归零。若持续增长,说明player.release()未被执行。

4.3 优化后台播放保活:ForegroundService 通知配置

ForegroundService要求必须显示通知,否则 Android 9+ 会强制停止服务。在MusicService.ktonCreate()中添加通知构建逻辑:

// MusicService.kt onCreate() 中 player 初始化后追加 private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( "music_playback", "音乐播放", NotificationManager.IMPORTANCE_LOW ).apply { description = "显示播放控制按钮" setShowBadge(false) // 隐藏角标 } val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } } private fun startForegroundNotification() { val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } val pendingIntent = PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) val notification = NotificationCompat.Builder(this, "music_playback") .setContentTitle("音乐播放器 2.0") .setContentText("正在播放...") .setSmallIcon(R.drawable.ic_music_note) .setContentIntent(pendingIntent) .setOngoing(true) // 关键:标记为持续运行,用户无法清除 .addAction(R.drawable.ic_skip_previous, "上一首", getPendingIntent("prev")) .addAction(R.drawable.ic_play, "播放/暂停", getPendingIntent("play")) .addAction(R.drawable.ic_skip_next, "下一首", getPendingIntent("next")) .build() startForeground(1, notification) } private fun getPendingIntent(action: String): PendingIntent { val intent = Intent(this, MusicService::class.java).apply { action = action } return PendingIntent.getService( this, action.hashCode(), intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) }

参数说明setOngoing(true)是保活核心,它使通知不可滑动清除;FLAG_IMMUTABLE是 Android 12+ 强制要求,避免 PendingIntent 被篡改;getPendingIntent()action.hashCode()作为 requestCode,确保每个 Action 有唯一标识。


5. 进阶技巧:用 MediaSessionConnector 实现播放列表无缝切换

5.1 为什么需要 MediaSessionConnector?

MediaSession默认只管理单个ExoPlayer实例,当需实现“播放列表循环”、“随机播放”、“跳过已播放曲目”时,需手动维护player.setMediaItems()并同步MediaSessionMediaMetadataMediaSessionConnector是 Media3 提供的高级封装,它自动将ExoPlayerMediaItem切换、PlaybackExceptionTimeline变化映射到MediaSession的标准行为。

5.2 集成 MediaSessionConnector 替代手动 Callback

MusicService.ktonCreate()中替换原有MediaSession.Builder逻辑:

// MusicService.kt onCreate() 中,删除原 mediaSession 初始化代码,替换为: private lateinit var connector: MediaSessionConnector override fun onCreate() { super.onCreate() player = ExoPlayer.Builder(this).build() // 1. 创建 MediaSession(不设 Callback) mediaSession = MediaSession.Builder(this, player) .setId("music_session") .build() // 2. 创建 Connector 并绑定 Player 与 Session connector = MediaSessionConnector(mediaSession) connector.setPlayer(player) // 3. 注册自定义播放逻辑(替代原 onPlay/onPause) connector.setPlaybackPreparer(object : PlaybackPreparer() { override fun onPrepareFromMediaId( mediaId: String, notify: Boolean, extras: Bundle? ) { // 根据 mediaId 加载对应 MediaItem(如从数据库查) val mediaItem = getMediaItemById(mediaId) player.setMediaItem(mediaItem) if (notify) player.prepare() } }) // 4. 启动前台服务 createNotificationChannel() startForegroundNotification() }

5.3 实现播放列表管理:支持 shuffle 与 repeat

MusicService.kt中添加播放列表工具方法:

private fun getMediaItemById(mediaId: String): MediaItem { // 示例:从预置列表中查找(实际应从 Room 数据库或 ContentProvider 加载) return when (mediaId) { "song_001" -> buildMediaItem("content://media/external/audio/media/1001", "晴天", "周杰伦") "song_002" -> buildMediaItem("content://media/external/audio/media/1002", "七里香", "周杰伦") else -> buildMediaItem("content://media/external/audio/media/1001", "晴天", "周杰伦") } } private fun buildMediaItem(uri: String, title: String, artist: String): MediaItem { return MediaItem.Builder() .setUri(uri) .setMediaId(uri.hashCode().toString()) .setMediaMetadata( MediaMetadata.Builder() .setTitle(title) .setArtist(artist) .build() ) .build() } // 在 Controller 端触发播放(如 MainActivity 中) fun playPlaylist(songIds: List<String>) { val mediaItems = songIds.map { getMediaItemById(it) } player.setMediaItems(mediaItems) player.prepare() player.seekToDefaultPosition() // 从第一首开始 }

关键优势MediaSessionConnector自动处理player.seekToNext()时的MediaSession状态同步,无需手动调用mediaSession.setMetadata();当player抛出PlaybackException,它会自动触发MediaSession的错误回调,便于前端展示 Toast。这是 2.0 版本区别于 1.0 的核心工程实践——用官方抽象层替代手工状态同步,降低出错概率。

最后一步,确保MusicServiceAndroidManifest.xml中的android:foregroundServiceType属性值为"mediaPlayback",这是 Android 12+ 系统识别媒体服务的唯一标识,缺一不可。

本文还有配套的精品资源,点击获取

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

AI智能体选型四维标尺:意图锚定、知识活化、行动闭环与进化韧性

1. 这不是选“AI工具”&#xff0c;是在选你的数字分身&#xff1a;为什么2026年智能体选择突然变得致命&#xff1f;“AI智能体到底怎么选&#xff1f;”——这句话在2026年已经不是技术圈内部的讨论&#xff0c;而是销售总监晨会的第一议题、自由职业者接单前的必查清单、甚至…

作者头像 李华
网站建设 2026/9/16 7:17:37

K8s-零基础入门

Kubernetes 零基础入门&#xff1a;从认识概念到部署第一个网站 这篇教程面向第一次接触 Kubernetes 的读者。你不需要先记住所有缩写&#xff0c;先弄懂它解决什么问题&#xff0c;再跟着练习部署一个网站。 一、什么是 K8s&#xff1f; Kubernetes 是一个用于自动化部署、…

作者头像 李华
网站建设 2026/9/16 7:16:41

计算机三级:STP生成树协议

计算机三级&#xff1a;STP生成树协议一. 生成树协议STP二. STP的工作过程为2.1 BPDU(网桥协议数据单元)三. 交换机STP配置3.1 PortFast3.2 UplinkFast(直接链路失效)3.3 BackboneFast3.4 BPDU Filtering四. 例题一. 生成树协议STP 生成树协议STP是一种工作在 OSI参考模型第二…

作者头像 李华
网站建设 2026/9/16 7:16:37

JAVA-课堂笔记01

预科07 基本的DOS命令#盘符切换#查看当前目录下的所有文件 dir#切换目录 cd change directorycd ..#清理屏幕 cls(清理屏幕)#退出终端 exit#查看电脑的ip ipconfig#打开应用calcmspaintnotepad#ping命令ping www.baidu.com#文件操作md 文件名rd 目录名cd>文件名del…

作者头像 李华
网站建设 2026/9/16 7:15:39

Packet Tracer 9.0入门指南:从CCNA实验到网络排错实战

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

作者头像 李华