首页/新闻资讯/正文详情

Android轻量级AVPlayer封装:基于MediaPlayer的播放器实战

发布时间:2026/9/25 6:20:01 来源:云帆数科 栏目:资讯中心
Android轻量级AVPlayer封装:基于MediaPlayer的播放器实战
1. 为什么我要自己撸一个Android AVPlayer做Android开发这些年多媒体播放这块踩的坑实在太多了。系统自带的MediaPlayer看着简单真到项目里用起来各种格式兼容性问题、seek不准、状态回调混乱尤其是产品那边提需求说“要支持倍速、要能切字幕、要能记住播放进度”MediaPlayer直接歇菜。ExoPlayer功能倒是全但引入依赖之后包体积蹭蹭往上涨一个简单的音频播放场景为了几个API把整个库拖进来说实话有点杀鸡用牛刀的感觉。后来我干脆自己封装了一套轻量级的AVPlayer组件核心思路就一个用最少的代码把MediaPlayer和SurfaceView/TextureView组合起来对外暴露一套干净、好理解的API。这套东西我前后在三个项目里用过从音频播客到短视频预览都扛得住代码量控制在几百行以内没有第三方依赖直接拷进项目就能跑。这篇文章我会把这套AVPlayer的设计思路、核心实现、踩过的坑和排查技巧全部摊开讲。适合谁看如果你正在做Android多媒体相关的功能或者你受够了MediaPlayer那套难用的状态机又不想引入重型播放库那这篇内容应该能帮你省不少时间。我会尽量把每个设计决策背后的“为什么”讲清楚让你看完能直接复现一套属于自己的播放器。2. 整体架构设计与核心思路拆解2.1 为什么选MediaPlayer而不是ExoPlayer或IJKPlayer先说选型这件事。市面上Android端做播放的方案无非就那么几种系统MediaPlayer、Google的ExoPlayer、B站的IJKPlayer还有基于FFmpeg自己编译的。我选MediaPlayer的理由很直接零依赖不用加任何gradle依赖不用管so库的ABI兼容问题APK体积不会因为播放器涨哪怕1KB。系统级支持MediaPlayer底层走的是系统多媒体框架硬解码支持是天然的功耗和性能在大多数场景下够用。API简单虽然状态机有点绕但核心方法就那么几个封装一层之后对外可以做到很好用。当然它也有明显的短板不支持DASH、HLS的自适应码率切换HLS在部分版本上可以播但控制力弱不支持自定义渲染器字幕支持基本为零。所以我的定位很明确——这套AVPlayer适合“播放本地或直链媒体文件”的场景比如音频播放器、短视频预览、课程视频播放这类需求。如果你要做直播或者复杂的流媒体那还是老老实实上ExoPlayer。注意MediaPlayer在Android 9API 28之后对明文HTTP请求做了限制如果你的媒体地址是http开头的记得在manifest里配置android:usesCleartextTraffictrue或者直接用https。2.2 封装层次与模块划分我的封装分三层第一层是AVPlayerCore直接持有MediaPlayer实例负责所有底层调用和状态管理。这一层不涉及任何UI纯逻辑。第二层是AVPlayerView继承自FrameLayout内部包含一个SurfaceView或TextureView和一个进度条。它持有AVPlayerCore的引用把播放画面和控制器绑定在一起。第三层是AVPlayerController一个接口层定义了播放、暂停、seek、设置数据源等操作方便在Activity或Fragment里调用也方便做单元测试时mock。这样分层的好处是如果你只需要音频播放可以只用AVPlayerCore不引入任何View相关的代码如果你需要自定义UI可以只复用Core层自己写控制界面。2.3 状态机的设计把MediaPlayer的坑填平MediaPlayer最让人头疼的就是它的状态机。你如果在错误的状态下调用了错误的方法它不会给你友好的提示直接抛IllegalStateException。比如你在Idle状态下调start()或者在Preparing状态下调seekTo()都会崩。我的做法是在AVPlayerCore里维护一个自己的状态枚举public enum PlayerState { IDLE, // 初始状态 INITIALIZED, // setDataSource之后 PREPARING, // prepareAsync调用后 PREPARED, // onPrepared回调后 STARTED, // start调用后 PAUSED, // pause调用后 STOPPED, // stop调用后 COMPLETED, // 播放完成 ERROR // 出错 }每次对外暴露操作之前先检查当前状态是否允许该操作。比如seekTo只在PREPARED、STARTED、PAUSED、COMPLETED这几个状态下才执行其他状态直接忽略并打日志。这样上层调用者就不用关心MediaPlayer那套复杂的状态约束了。2.4 渲染方案SurfaceView还是TextureView这是个老生常谈的问题。SurfaceView的优点是性能好它有自己的独立绘图层不占用主线程的绘制资源缺点是它不在View的层级里做动画、变换、圆角裁剪都很麻烦。TextureView正好相反它像普通View一样参与绘制可以做各种变换但性能略差而且需要硬件加速支持。我的选择是默认用SurfaceView但保留TextureView的切换能力。具体做法是在AVPlayerView的构造函数里读一个自定义属性让使用者在XML里就能指定用哪种com.example.avplayer.AVPlayerView android:layout_widthmatch_parent android:layout_heightwrap_content app:render_typesurface /对于短视频列表这种需要频繁滑动的场景SurfaceView更合适对于需要做画面旋转、缩放动画的场景TextureView更灵活。3. 核心细节解析与实操要点3.1 MediaPlayer的生命周期管理MediaPlayer对象是很重的资源它持有解码器、音频输出通道等系统资源。如果你不及时释放轻则内存泄漏重则导致其他应用无法使用音频设备。我的做法是在AVPlayerCore里严格遵循“谁创建谁释放”的原则并且把release()方法做成幂等的——多次调用不会出问题public void release() { if (mediaPlayer ! null) { try { mediaPlayer.reset(); mediaPlayer.release(); } catch (Exception e) { Log.e(TAG, release error, e); } finally { mediaPlayer null; currentState PlayerState.IDLE; } } }然后在AVPlayerView的onDetachedFromWindow()里调用release()确保View从窗口移除时播放器也被释放。这里有个细节不要直接在onDestroy()里释放因为onDetachedFromWindow的调用时机更准确能覆盖View被移除但Activity还没销毁的情况。实操心得我见过很多项目在onPause()里调release()然后在onResume()里重新创建。这样做不是不行但用户体验很差——切个后台回来播放进度就没了。更好的做法是在onPause()里只调pause()在onDestroy()或onDetachedFromWindow()里才release()。3.2 异步准备与回调线程问题MediaPlayer的prepareAsync()是异步的它的回调onPrepared()是在主线程的Looper里执行的。这一点很重要因为很多人在回调里直接操作UI如果回调不在主线程就会崩。但如果你在回调里做了耗时操作又会阻塞主线程。我的处理方式是在AVPlayerCore里定义一个OnPlayerStateListener接口把onPrepared、onCompletion、onError这些回调统一转发出去并且明确文档说明这些回调都在主线程执行。如果调用者需要做耗时操作自己切到子线程去。mediaPlayer.setOnPreparedListener(mp - { currentState PlayerState.PREPARED; if (stateListener ! null) { stateListener.onPrepared(); } // 如果设置了自动播放准备完成后直接开始 if (autoPlay) { start(); } });这里有个坑setOnPreparedListener必须在prepareAsync()之前调用否则可能错过回调。我一般是在setDataSource()之后立刻设置所有监听器然后再调prepareAsync()。3.3 进度更新机制的设计播放进度更新看起来简单其实有很多细节。最朴素的做法是开一个Handler每隔500ms发一条消息去读getCurrentPosition()。但这样做有几个问题频率太高浪费资源频率太低进度条卡顿。暂停时不应该继续更新。seek之后进度要立刻刷新不能等下一个周期。我的方案是用一个Runnable配合Handler.postDelayed()间隔设为300ms实测下来这个值在流畅度和性能之间平衡得最好。在start()时启动轮询在pause()、stop()、release()时移除回调。seek操作完成后手动触发一次进度更新。private final Runnable progressUpdater new Runnable() { Override public void run() { if (currentState PlayerState.STARTED) { int current mediaPlayer.getCurrentPosition(); int duration mediaPlayer.getDuration(); if (progressListener ! null) { progressListener.onProgress(current, duration); } handler.postDelayed(this, PROGRESS_INTERVAL); } } };注意getDuration()在PREPARED状态之前调用会返回-1所以进度更新一定要在onPrepared之后才开始。3.4 音频焦点处理如果你的应用要播放音频必须处理音频焦点。否则会出现“我的应用在放歌突然来电话了歌还在响”这种尴尬情况。处理音频焦点的标准做法是使用AudioManager请求焦点并注册OnAudioFocusChangeListenerprivate boolean requestAudioFocus() { AudioManager am (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); int result am.requestAudioFocus( focusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN ); return result AudioManager.AUDIOFOCUS_REQUEST_GRANTED; }在焦点丢失时比如来电应该暂停播放焦点恢复时根据业务需求决定是否自动恢复播放。我的默认策略是短暂丢失AUDIOFOCUS_LOSS_TRANSIENT时暂停恢复后自动继续永久丢失AUDIOFOCUS_LOSS时暂停并释放焦点。4. 完整实操过程与核心环节实现4.1 项目结构搭建先在Android Studio里新建一个项目minSdkVersion我建议设成21Android 5.0这样能覆盖绝大多数设备同时可以用一些较新的API。然后在项目里新建一个module或者直接在app module里建包com.example.avplayer ├── core │ ├── AVPlayerCore.java │ ├── PlayerState.java │ └── OnPlayerStateListener.java ├── view │ ├── AVPlayerView.java │ └── AVPlayerController.java └── util └── TimeUtil.java不需要在gradle里加任何额外依赖纯原生实现。4.2 AVPlayerCore的完整实现先定义状态枚举和监听接口public enum PlayerState { IDLE, INITIALIZED, PREPARING, PREPARED, STARTED, PAUSED, STOPPED, COMPLETED, ERROR } public interface OnPlayerStateListener { void onPrepared(); void onCompletion(); void onError(int what, int extra); void onBufferingUpdate(int percent); }然后是核心类的骨架public class AVPlayerCore { private static final String TAG AVPlayerCore; private MediaPlayer mediaPlayer; private PlayerState currentState PlayerState.IDLE; private OnPlayerStateListener stateListener; private boolean autoPlay false; private Handler handler new Handler(Looper.getMainLooper()); public AVPlayerCore() { initMediaPlayer(); } private void initMediaPlayer() { mediaPlayer new MediaPlayer(); mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); mediaPlayer.setOnPreparedListener(mp - { currentState PlayerState.PREPARED; if (stateListener ! null) stateListener.onPrepared(); if (autoPlay) start(); }); mediaPlayer.setOnCompletionListener(mp - { currentState PlayerState.COMPLETED; if (stateListener ! null) stateListener.onCompletion(); }); mediaPlayer.setOnErrorListener((mp, what, extra) - { currentState PlayerState.ERROR; if (stateListener ! null) stateListener.onError(what, extra); return true; }); mediaPlayer.setOnBufferingUpdateListener((mp, percent) - { if (stateListener ! null) stateListener.onBufferingUpdate(percent); }); } }设置数据源的方法要处理本地文件和网络URL两种情况public void setDataSource(String path) { try { mediaPlayer.reset(); if (path.startsWith(http) || path.startsWith(rtsp)) { mediaPlayer.setDataSource(path); } else { mediaPlayer.setDataSource(path); } currentState PlayerState.INITIALIZED; } catch (IOException e) { Log.e(TAG, setDataSource failed, e); currentState PlayerState.ERROR; } }播放控制方法public void prepareAsync() { if (currentState ! PlayerState.INITIALIZED) { Log.w(TAG, prepareAsync called in wrong state: currentState); return; } currentState PlayerState.PREPARING; mediaPlayer.prepareAsync(); } public void start() { if (currentState PlayerState.PREPARED || currentState PlayerState.PAUSED || currentState PlayerState.COMPLETED) { mediaPlayer.start(); currentState PlayerState.STARTED; startProgressUpdate(); } } public void pause() { if (currentState PlayerState.STARTED) { mediaPlayer.pause(); currentState PlayerState.PAUSED; stopProgressUpdate(); } } public void seekTo(int positionMs) { if (currentState PlayerState.PREPARED || currentState PlayerState.STARTED || currentState PlayerState.PAUSED || currentState PlayerState.COMPLETED) { mediaPlayer.seekTo(positionMs); } }4.3 AVPlayerView的UI集成AVPlayerView继承FrameLayout内部动态添加SurfaceView和进度条public class AVPlayerView extends FrameLayout { private SurfaceView surfaceView; private SurfaceHolder surfaceHolder; private AVPlayerCore playerCore; private SeekBar seekBar; private boolean surfaceReady false; public AVPlayerView(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } private void init(Context context, AttributeSet attrs) { // 读取自定义属性决定渲染方式 TypedArray ta context.obtainStyledAttributes(attrs, R.styleable.AVPlayerView); int renderType ta.getInt(R.styleable.AVPlayerView_render_type, 0); ta.recycle(); if (renderType 0) { surfaceView new SurfaceView(context); } else { // 使用TextureView surfaceView new SurfaceView(context); // 简化处理 } LayoutParams params new LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); addView(surfaceView, params); surfaceHolder surfaceView.getHolder(); surfaceHolder.addCallback(new SurfaceHolder.Callback() { Override public void surfaceCreated(SurfaceHolder holder) { surfaceReady true; if (playerCore ! null) { playerCore.setDisplay(holder); } } Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {} Override public void surfaceDestroyed(SurfaceHolder holder) { surfaceReady false; } }); } }这里有个关键点SurfaceView的surfaceCreated回调时机和MediaPlayer的prepare时机可能不一致。如果MediaPlayer先准备好了但Surface还没创建视频就播不出来。我的处理方式是在AVPlayerCore里缓存SurfaceHolder等surfaceCreated之后再setDisplay。4.4 进度条与手势控制进度条我用的是系统SeekBar但做了两层处理一层是播放进度一层是缓冲进度。缓冲进度通过setSecondaryProgress()来设置数据来源是onBufferingUpdate回调。手势控制方面我实现了三个常用手势左右滑动快进/快退滑动距离映射到seek位置。上下滑动左侧调节亮度。上下滑动右侧调节音量。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: downX event.getX(); downY event.getY(); break; case MotionEvent.ACTION_MOVE: float deltaX event.getX() - downX; float deltaY event.getY() - downY; if (Math.abs(deltaX) Math.abs(deltaY)) { // 横向滑动处理快进快退 int seekDelta (int) (deltaX / getWidth() * duration); playerCore.seekTo(currentPosition seekDelta); } break; } return true; }实操心得手势的阈值要设得合理我一般用ViewConfiguration.get(context).getScaledTouchSlop()作为最小滑动距离避免误触。4.5 在Activity中的调用示例public class MainActivity extends AppCompatActivity { private AVPlayerView playerView; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); playerView findViewById(R.id.player_view); playerView.setDataSource(https://example.com/video.mp4); playerView.setAutoPlay(true); playerView.prepareAsync(); } Override protected void onPause() { super.onPause(); playerView.pause(); } Override protected void onDestroy() { super.onDestroy(); playerView.release(); } }5. 常见问题与排查技巧实录5.1 播放黑屏但有声音这是最常见的问题九成以上是Surface没有正确绑定。排查步骤检查surfaceCreated回调是否触发。如果没触发说明SurfaceView没有正确添加到布局里或者被其他View遮挡了。检查setDisplay()是否在prepareAsync()之前调用。如果顺序反了MediaPlayer可能已经准备好但找不到渲染目标。检查视频编码格式是否被设备支持。有些设备不支持H.265硬解会黑屏但有声音。我的解决方案是在AVPlayerCore里加一个pendingSurfaceHolder字段如果setDisplay时Surface还没准备好就先存起来等surfaceCreated后再设置。5.2 seek之后进度回调不更新MediaPlayer的seekTo是异步的调用之后不会立刻生效。如果你在seek后马上读getCurrentPosition()拿到的还是旧值。正确的做法是监听OnSeekCompleteListenermediaPlayer.setOnSeekCompleteListener(mp - { // seek完成手动触发一次进度更新 if (progressListener ! null) { progressListener.onProgress( mediaPlayer.getCurrentPosition(), mediaPlayer.getDuration() ); } });5.3 播放网络视频时缓冲卡顿网络视频的缓冲策略可以通过MediaPlayer.setBufferSize()来调整但这个API在API 26才加入。低版本设备上只能靠系统默认策略。我的经验是对于短视频5分钟可以先把整个文件下载到本地缓存再播放体验最稳。对于长视频建议用ExoPlayer它的缓冲控制更精细。如果坚持用MediaPlayer可以在onBufferingUpdate里根据percent动态显示加载动画至少让用户知道在缓冲。5.4 常见问题速查表问题现象可能原因解决方案播放崩溃IllegalStateException在错误状态调用了方法检查状态机加状态判断有声音无画面Surface未绑定或格式不支持检查setDisplay时机确认编码格式进度条不动未启动进度轮询或getDuration返回-1在onPrepared后启动轮询切后台回来播放停止onPause里调了release改为只pauseonDestroy才release音频焦点丢失后不恢复未处理AUDIOFOCUS_GAIN回调在焦点恢复回调里判断是否继续播放内存泄漏MediaPlayer未释放在onDetachedFromWindow里release5.5 性能优化建议最后分享几个实测有效的优化点复用MediaPlayer实例如果是在列表里播放不要每个item都new一个MediaPlayer用一个全局的播放器切换数据源时调reset()即可。避免频繁seekseek操作很重如果用户拖动进度条不要每次move都seek等ACTION_UP时再seek一次。合理设置缓冲区对于本地文件可以把缓冲区设小一点减少内存占用对于网络文件适当加大。使用硬解码MediaPlayer默认走硬解码但如果你发现某些格式播放卡顿可以尝试在setDataSource之前设置mediaPlayer.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT)。这套AVPlayer我在实际项目里跑了两年多从简单的音频播放到短视频预览都覆盖过稳定性没问题。代码量不大但每个细节都是踩坑踩出来的。如果你也在做类似的东西希望这些经验能帮你少走点弯路。后面如果要做更复杂的功能比如弹幕、字幕、多音轨切换那确实得上ExoPlayer但在那之前这套轻量方案足够撑住大多数场景了。

相关推荐

AI科研助手Skills实战:从GitHub选型到安装定制全指南
AI科研助手Skills实战:从GitHub选型到安装定制全指南

很多人第一次听到“AI科研助手Skills”这个概念时,第一反应大概率是“这不就是给AI写个提示词模板吗”。说实话,我一开始也是这么想的,直到我自己动手在GitHub上翻了几十个相关仓库、踩了一堆安装和调用的坑之后,才意识到这里面的… · 2026/9/25 6:20:01

Lithe-IDEA:开源轻量IDE的依赖压缩与运行时优化实践
Lithe-IDEA:开源轻量IDE的依赖压缩与运行时优化实践

1. 这不是另一个“IDEA精简版”,而是开发者对工具链主权的一次务实重构你有没有过这样的时刻:打开 IDEA 社区版,刚加载完项目,CPU 就开始嘶吼,内存占用直逼 3GB;想装个插件优化启动速度,结果发现… · 2026/9/25 6:19:55

STM32开源项目三大硬性标准:代码+原理图+仿真闭环验证
STM32开源项目三大硬性标准:代码+原理图+仿真闭环验证

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

@turf/bbox-polygon 完全指南:将地理包围盒(BBox)转换为 GeoJSON Polygon
@turf/bbox-polygon 完全指南:将地理包围盒(BBox)转换为 GeoJSON Polygon

数据分析 【免费下载链接】turf A modular geospatial engine written in JavaScript and TypeScript 项目地址: https://gitcode.com/gh_mirrors/tu/turf 点击查看 免费下载 本文以 Turf 模块化地理引擎中的 turf/bbox-polygon 模块为核心,讲解如何把形… · 2026/9/25 6:50:05

OpenClaw-China-Docker部署教程:docker-compose与.env环境变量完全指南,附极简起步清单
OpenClaw-China-Docker部署教程:docker-compose与.env环境变量完全指南,附极简起步清单

OpenClaw-China-Docker部署教程:docker-compose与.env环境变量完全指南,附极简起步清单 【免费下载链接】openclaw-china-docker OpenClaw 的中国IM平台整合Docker版本,预装并配置了飞书、钉钉、QQ机器人、企业微信等主流中国IM软件的插件&am… · 2026/9/25 6:50:05

Apache DataFusion 语义规范解读:逻辑/物理平面不变量与输出字段名生成规则
Apache DataFusion 语义规范解读:逻辑/物理平面不变量与输出字段名生成规则

大数据数据分析后端 【免费下载链接】datafusion Apache DataFusion SQL Query Engine 项目地址: https://gitcode.com/gh_mirrors/datafu/datafusion 点击查看 免费下载 本文围绕 Apache DataFusion 官方规格说明(Specification)体系&#… · 2026/9/25 6:49:59

RocketRide media_inspect 节点实战:流式媒体的探测、响度测量与 JPEG 截帧
RocketRide media_inspect 节点实战:流式媒体的探测、响度测量与 JPEG 截帧

【免费下载链接】rocketride-server High-performance AI pipeline engine with a C core and 50 Python-extensible nodes. Build, debug, and scale LLM workflows with 13 model providers, 8 vector databases, and agent orchestration, all from your IDE. Includes VS C… · 2026/9/25 6:49:59

Pyro 杂项算子库(pyro.ops)完全指南:从 HMC 数值工具到高斯收缩与流式统计
Pyro 杂项算子库(pyro.ops)完全指南:从 HMC 数值工具到高斯收缩与流式统计

人工智能机器学习深度学习概率编程 【免费下载链接】pyro Deep universal probabilistic programming with Python and PyTorch 项目地址: https://gitcode.com/gh_mirrors/py/pyro 点击查看 免费下载 Pyro 的 pyro.ops 模块实现了一整套与概率编程主体解耦的张量数… · 2026/9/25 6:49:59

Ubuntu视频播放软件全解析:VLC、MPV、SMPlayer与Totem选型及硬件加速配置指南
Ubuntu视频播放软件全解析:VLC、MPV、SMPlayer与Totem选型及硬件加速配置指南

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

数值优化(Numerical Optimization)学习系列-03-共轭梯度方法(Conjugate Gradient)
数值优化(Numerical Optimization)学习系列-03-共轭梯度方法(Conjugate Gradient)

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

创维E900V22D刷机全攻略:S905L3SB芯片兼容性解析与救砖实战
创维E900V22D刷机全攻略:S905L3SB芯片兼容性解析与救砖实战

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

MQTT协议原理与Broker服务器搭建实战:从Mosquitto到EMQX
MQTT协议原理与Broker服务器搭建实战:从Mosquitto到EMQX

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

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码