尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
FFmpeg与ANativeWindow:Android自研播放器渲染实践
简介一套面向安卓开发者的视频播放器项目源码演示如何通过开源多媒体框架完成视频解码并将解码后的画面帧渲染到安卓原生窗口实现流畅的原生播放。项目采用C/C混合编写属于安卓原生开发的高级主题适合具备一定音视频基础、希望深入掌握多媒体解码与图形渲染的开发者研究。资源压缩包共包含一百三十六个文件以大量头文件为基础配合多份C核心源码、安卓工程配置文件、Java入口、构建脚本及动态库整体仅六点七九兆字节结构清晰便于按模块查阅目前已有三百八十三人学习下载。从源码中可以完整学习媒体容器解析、解码器调用与打开、逐帧读取、颜色空间转换、原生窗口缓冲区申请与提交、解码与渲染线程同步等关键环节并涉及像素格式转换、时间戳对齐、错误处理与性能调优策略项目还提供了构建动态库与工程目录组织方式是一份深入理解安卓原生多媒体实现的实用参考资料。 老实说我在自己做播放器模块之前一直觉得 Android 上的视频播放只要交给 MediaPlayer 或者 ExoPlayer 就够了。直到有一次我需要对接一个特殊的视频封装格式还要在解码后做逐帧的滤镜处理Java 层的 MediaCodec 方案怎么都不顺手。MediaCodec 拿到的是压缩码流硬件解码器的输出 buffer 走的是 Surface 或者 SurfaceTexture你要想在帧级别干预像素数据绕来绕去都得经历几次拷贝而且很多国产设备对 MediaCodec 的兼容性表现一言难尽。这种情况就逼着我去看 FFmpeg ANativeWindow 这条路。这篇文章就是把我实际搭建 FFmpegANativeWindow 播放链路的完整过程写出来。适合那些不想依赖系统播放器、希望自己控制解码和渲染节奏的 Android 开发也适合正在做 NDK 音视频方案的读者。FFmpeg 负责解封装和解码ANativeWindow 负责把解码后的像素数据直接投递到 Surface 上显示整个过程不经过 Java 层的 Bitmap 或 Canvas是一条非常直接的 native 渲染路径。1. 为什么绕开 Java 层ANativeWindow 方案的定位与适用场景1.1 什么时候必须自己用 FFmpeg 解码而不是 MediaCodec媒体播放这件事系统提供的 MediaPlayer 和 ExoPlayer 在绝大多数场景下都够用性能也好。但有几个场景是系统播放器很难覆盖的第一你需要使用 FFmpeg 的 filter_graph 对画面做自定义处理比如水印、裁剪、像素效果这些在硬件解码器上做非常痛苦因为输出 buffer 是 YUV 格式的硬件 buffer你很难直接复用第二项目里已经有大量基于 FFmpeg 的逻辑代码比如自研协议、特殊容器解析、自定义流切片这时候再引入一套 MediaCodec 来解码会破坏整体链路第三也是最重要的学习价值。通过 FFmpeg 的完整解码链路你能真正理解 DTS/PTS、codec_ctx、sws_scale 这些概念而 MediaCodec 把这些都封装成了黑盒。用 MediaCodec 时解码器的输出要么是 Surface要么是 ByteBuffer。Surface 路径高效但没法拿到帧数据做处理ByteBuffer 路径能拿到数据但效率低而且要处理格式协商不同设备的 codec 输出格式还不一样。FFmpeg 全软件解码虽然 CPU 占用高一些但所有数据和格式都在你手里可控性是完全不同的。1.2 ANativeWindow 与 Surface 的关系以及它解决的问题ANativeWindow 是 Android NDK 提供的原生窗口接口本质上是 Java 层 Surface 对象在 native 层的对应体。你通过ANativeWindow_fromSurface(JNIEnv*, jobject surface)拿到一个ANativeWindow*之后就可以用 ANativeWindow 系列 API 来向这个窗口提交图像数据。它底层直接操作的是 Surface 的 buffer queue和 Java 层的 SurfaceHolder.lockCanvas() 相比少了几层 JNI 转换而且你提交的是原始像素数据不需要经过 Canvas 绘制也完全可以和 OpenGL ES 配合使用。从我实际使用的体验来看ANativeWindow 最大的价值在于它让你在纯 native 代码里就可以控制画面输出不需要 Java 层参与。解码线程在 C/C 里跑渲染也在 C/C 里跑整个播放循环是一个独立的 native 模块这对工程上的模块化非常友好。2. 环境准备FFmpeg 库与 CMake 集成2.1 FFmpeg 的获取方式选择要在 Android 上使用 FFmpeg通常有两种方式自己用 NDK 交叉编译或者直接引入预编译好的 so。自编译的好处是可以裁剪出你需要的模块体积更小。比如我的项目只用到 avformat、avcodec、avutil、swscale、swresample 这几个库configure 的时候可以关掉不需要的封装格式和编解码器减少 30% 甚至更多的体积。但自编译的坑比较多主要是 NDK 版本和 FFmpeg 版本的选择以及优化参数的设置。如果你只是要打通播放链路推荐先引入现成的预编译库把业务逻辑跑通之后再考虑裁剪。我这里的项目用的是 FFmpeg 5.x 以上的版本编译出一个 Android 用的 so 集合然后通过 CMake 导入。一个关键点是 FFmpeg 日志默认输出到 stderr在 Android 的 logcat 里看不到需要设置一个自定义的日志回调否则调试起来非常痛苦。2.2 CMakeLists.txt 的最小配置假设你的 FFmpeg 库被放在src/main/cpp/ffmpeg目录下头文件在includeso 文件在libs/arm64-v8aCMake 配置可以写成这样cmake_minimum_required(VERSION 3.22.1) project(ffmpeg_player) set(CMAKE_CXX_STANDARD 17) add_library(avformat SHARED IMPORTED) set_target_properties(avformat PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/ffmpeg/libs/${ANDROID_ABI}/libavformat.so) add_library(avcodec SHARED IMPORTED) set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/ffmpeg/libs/${ANDROID_ABI}/libavcodec.so) add_library(avutil SHARED IMPORTED) set_target_properties(avutil PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/ffmpeg/libs/${ANDROID_ABI}/libavutil.so) add_library(swscale SHARED IMPORTED) set_target_properties(swscale PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/ffmpeg/libs/${ANDROID_ABI}/libswscale.so) add_library(native_player SHARED native_player.cpp) target_include_directories(native_player PRIVATE ${CMAKE_SOURCE_DIR}/ffmpeg/include) target_link_libraries(native_player android log avformat avcodec avutil swscale)注意一定要把android库链接进来因为 ANativeWindow 相关 API 在 libandroid.so 里如果没有链接编译可能不报错但运行时会出现 undefined symbol 崩溃。2.3 JNI 接口设计Java 层我采用的是非常直观的接口设计一个播放器类大概有这些 native 方法public class FFPlayer { private long nativeHandle; private Surface surface; public native void setSurface(Surface surface); public native void start(String videoPath); public native void pause(); public native void resume(); public native void stop(); public native void release(); }这里有一个很容易踩坑的注意点Surface 不能直接以普通对象的方式缓存一定要在 Java 层持有引用同时在 native 层通过NewGlobalRef持有 JNI 全局引用防止 Surface 被 GC 回收。我在实现的时候是直接把 Surface 对象传进去在 native 层创建全局引用同时用 ANativeWindow_fromSurface 拿到窗口句柄。Java 层每次调用 start 之前都要确保已经传入一个有效的 Surface。3. 解码到帧FFmpeg 侧的数据链路3.1 初始化打开文件、定位视频流、打开解码器FFmpeg 的解码链路其实非常套路化只要你写过一次后面就是重复。第一步是初始化所有需要的组件extern C { #include libavformat/avformat.h #include libavcodec/avcodec.h #include libswscale/swscale.h #include libavutil/imgutils.h } // 初始化 avformat_network_init(); AVFormatContext* format_ctx avformat_alloc_context(); if (avformat_open_input(format_ctx, file_path, nullptr, nullptr) ! 0) { // 文件打开失败 } if (avformat_find_stream_info(format_ctx, nullptr) 0) { // 无法读取流信息 } // 找到视频流索引 int video_stream_index av_find_best_stream(format_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); AVStream* video_stream format_ctx-streams[video_stream_index]; // 打开解码器 const AVCodec* codec avcodec_find_decoder(video_stream-codecpar-codec_id); AVCodecContext* codec_ctx avcodec_alloc_context3(codec); avcodec_parameters_to_context(codec_ctx, video_stream-codecpar); if (avcodec_open2(codec_ctx, codec, nullptr) ! 0) { // 解码器打开失败 }这中间有两点值得强调。第一av_find_best_stream比手动遍历format_ctx-streams找codec_type AVMEDIA_TYPE_VIDEO更可靠因为它会综合考虑流的信息第二老版本里很多人会用stream-codec这个字段来获取 codec 参数但从 FFmpeg 4.0 开始这个字段被移除了正确做法是读取stream-codecpar然后通过avcodec_parameters_to_context转给 codec_ctx。我第一次从旧代码迁移的时候这里卡了挺久。3.2 解码循环从 av_read_frame 到 AVFrame打开解码器之后就可以进入主循环。主流程是先av_read_frame读取一个 AVPacket判断是不是视频流的数据如果是就扔给解码器解码出一个或多个 AVFrame然后把 AVFrame 交给后续的格式转换和渲染。AVPacket* packet av_packet_alloc(); AVFrame* frame av_frame_alloc(); while (av_read_frame(format_ctx, packet) 0) { if (packet-stream_index video_stream_index) { int ret avcodec_send_packet(codec_ctx, packet); if (ret 0) { // 发送失败通常不需要特殊处理 } while (ret 0) { ret avcodec_receive_frame(codec_ctx, frame); if (ret AVERROR(EAGAIN) || ret AVERROR_EOF) { break; } else if (ret 0) { break; } // 到这里拿到了一帧解码后的数据 render_frame(codec_ctx, frame); } } av_packet_unref(packet); } // 解码结束 avcodec_send_packet(codec_ctx, nullptr); while (avcodec_receive_frame(codec_ctx, frame) 0) { render_frame(codec_ctx, frame); }这里render_frame里要做的事是把 frame 转换成渲染所需的像素格式。FFmpeg 解码出来的原始帧一般是 YUV420P而 ANativeWindow 最稳定通用的操作格式是 RGBA_8888所以要用 swscale 做一次格式转换。关于什么格式最适合 ANativeWindow我后面在性能章节会详细讲这里先按最稳妥的方案来。3.3 sws_scale 转换YUV 到 RGBA 的桥格式转换用到 libswscalevoid render_frame(AVCodecContext* codec_ctx, AVFrame* frame) { // 分配转换器 SwsContext* sws_ctx sws_getContext( frame-width, frame-height, (AVPixelFormat)frame-format, frame-width, frame-height, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr ); uint8_t* rgba_data[4] {nullptr}; int rgba_linesize[4] {0}; av_image_alloc(rgba_data, rgba_linesize, frame-width, frame-height, AV_PIX_FMT_RGBA, 1); sws_scale(sws_ctx, frame-data, frame-linesize, 0, frame-height, rgba_data, rgba_linesize); // 现在 rgba_data[0] 里面就是 RGBA 格式的像素数据linesize 是每行字节数 // 渲染到 ANativeWindow... av_freep(rgba_data[0]); sws_freeContext(sws_ctx); }这个函数每次创建 SwsContext 是比较低效的实际工程里应该在解码之前就把这 28 个结构体分配好这里写出来主要是为了让你看清整个流程。线条尺寸值rgba_linesize[0]非常关键它可能和width * 4不同因为有些平台为了对齐会在每行末尾填充额外字节拷贝到 ANativeWindow 时必须以 linesize 为准。4. 让画面出现在屏幕上ANativeWindow 渲染4.1 ANativeWindow 的完整操作流程渲染部分ANativeWindow 的核心 API 其实很少就三个lock、copy、unlock。void render_to_anativewindow(ANativeWindow* native_window, uint8_t* rgba_data, int width, int height, int linesize) { ANativeWindow_acquire(native_window); ANativeWindow_Buffer buffer; // 设置窗口 buffer 的几何尺寸和像素格式 ANativeWindow_setBuffersGeometry(native_window, width, height, WINDOW_FORMAT_RGBA_8888); if (ANativeWindow_lock(native_window, buffer, nullptr) 0) { ANativeWindow_release(native_window); return; } // 逐行拷贝像素数据到窗口 buffer for (int y 0; y height; y) { memcpy((uint8_t*)buffer.bits y * buffer.stride * 4, rgba_data y * linesize, width * 4); } ANativeWindow_unlockAndPost(native_window); ANativeWindow_release(native_window); }这里最核心的知识点就是buffer.stride和视频宽度的关系。ANativeWindow 的 buffer 行字节数往往不等于width * 4它通常会对齐到 8 或者 16 的整数倍所以上面代码里拷贝到第 y 行时目标地址要跳过buffer.stride * 4个字节而不是width * 4。如果你把width * 4当作每行字节数画面会出现一条一条的斜向偏移或色带这是新手最容易踩的坑而且看代码时很难发现逻辑错误。4.2 关于 PixelCopy 的细节有一些网上的代码直接写memcpy(buffer.bits, rgba_data, size)这是典型的错误写法。首先ANativeWindow_Buffer的stride是像素单位而不是字节单位而且不同设备上 stride 可能不同。其次RGBA 数据一帧可能有 108019204 个字节一次性 memcpy 假设 src 和 dst 的内存布局完全一致但实际上 dst 因为 stride 对齐的关系每行末尾可能有填充字节这就会导致从第二行开始所有数据都错位。正确做法永远是一行一行拷贝。4.3 帧率控制别让你的解码线程跑满 CPU解码循环如果不控制节奏会以最快的速度解码所有帧并往 ANativeWindow 上刷这样播放速度远快于正常播放速率。最简单的做法是解码后对 PTS 做等待但在开始阶段我建议直接用usleep配合帧率值做一个粗糙的定时double frame_delay 1.0 / fps; // 从 video_stream-avg_frame_rate 换算 ... render_frame(...); // 等待 usleep(frame_delay * 1000000);这种做法只能保证大致速度。如果后续要做音视频同步就得换成基于 PTS 的同步方案但初版播放器先用这个方案把画面跑起来最直接。5. 实际开发中的性能坑与细节处理5.1 格式不匹配导致的黑屏与花屏我在最初搭建这个播放器时ANativeWindow_setBuffersGeometry 设置的格式用的是WINDOW_FORMAT_RGBA_8888但 sws_scale 输出用的是AV_PIX_FMT_ABGR结果在部分设备上出现了颜色通道对调、整体偏蓝的情况。这个问题的根源是字节序AV_PIX_FMT_RGBA 在内存里的字节序是 R,G,B,A而 ANativeWindow 的 WINDOW_FORMAT_RGBA_8888 期望的字节序取决于硬件在某些 SoC 上实际期望的是 B,G,R,A。一个可行的方法是保持 sws_scale 输出为 AV_PIX_FMT_RGBA然后在渲染时做一次通道交换。但这样会多一次像素遍历对性能有影响。更推荐的做法是在 FFmpeg 转换时分别测试AV_PIX_FMT_RGBA和AV_PIX_FMT_BGRA以你的目标设备实际显示效果为准。在我的主力机上AV_PIX_FMT_RGBA是正常的但在另一台平板上就偏色了。遇到偏色问题优先检查这一层。5.2 分辨率调整与旋转ANativeWindow_setBuffersGeometry 的 width 和 height 决定了显示区域的大小不一定非要和视频原始分辨率一致。如果你设置的和视频帧宽高比不同画面会被拉伸。更关键的是很多视频的显示宽高比不等于实际像素宽高比比如 16:9 的视频可能像素是 1024x576显示时拉伸到 1920x1080。建议在setBuffersGeometry时直接设置成显示目标分辨率让系统的 buffer 用对应尺寸然后用 sws_scale 直接把视频帧缩放到这个尺寸一次性完成转换和缩放。旋转信息也不是所有视频都存在的但有些视频的stream-metadata里带有 rotate 标签比如 90 度旋转。此时你在渲染前需要判断是否要交换宽高否则画面会躺倒。我记得我那次处理一个手机竖屏拍摄的视频时就因为这个旋转信息没处理画面一直横着折腾了半天才发现不是解码问题。5.3 Surface 的内存生命周期管理ANativeWindow 的使用需要注意一个生命周期的问题ANativeWindow_fromSurface得到的窗口指针在使用期间必须保持有效否则调用ANativeWindow_lock时会直接崩溃或返回错误。在 Java 层如果 SurfaceView 被销毁或者重新布局Surface 也会失效。所以安全做法是Java 层持有 Surface 引用不要把它交给 GC 回收。native 层在每次渲染前检查 native_window 是否为空。在 SurfaceHolder.Callback 的 surfaceDestroyed 回调里主动通知 native 层释放当前的 ANativeWindow然后重新等待新的 Surface。我在写播放器时因为一开始没有处理 surfaceDestroyed导致退出界面后偶发性崩溃排查到最后发现是 dangling pointer 访问了已释放的 ANativeWindow这个问题在 native 开发里属于常见的致命失误值得提醒一下。6. 线程模型与更进一步的优化方向6.1 单线程如何稳定双线程如何提升流畅度初版我是在一个子线程里跑完整的解码 渲染循环单线程的优点是简单可靠不需要考虑线程同步和死锁而且 FFmpeg 的解码和渲染天然是串相继的关系不会出现画面撕裂。缺点是解码耗时波动I帧 vs P帧和渲染耗时叠加后可能导致卡顿。如果你的播放器处理的是高码率视频我建议把解码和渲染拆成两个线程中间用队列传递待显示的帧。解码线程持续从文件读取 packet 并解码成 frame渲染线程从队列里取 frame 并调用 ANativeWindow 渲染。这样即使某一次解码耗时较长渲染线程依然有缓冲帧可以继续显示不会导致画面卡顿。队列可以用互斥锁 条件变量实现设置最大长度来限制内存占用。诚实地讲如果只是做一个基础播放器单线程也够用线程模型是为了后续复杂功能打的底子。6.2 减少不必要的内存拷贝目前的链路中YUV 帧先由 sws_scale 转成 RGBA再逐行拷贝到 ANativeWindow 的 buffer。转色是必须的但第二次的 memcpy 实际上是可以优化的有的平台支持在锁定 buffer 后直接获取到连续内存而 sws_scale 可以直接输出到 ANativeWindow buffer 的地址上。这样你只要用rgba_data[0] (uint8_t*)buffer.bits然后再调 sws_scale就省掉了逐行 memcpy。这需要你在打开解码器之前就拿到 buffer我建议初版先不这么干等逻辑稳定后再来优化拷贝。另一个更大的优化方向是让 FFmpeg 直接输出 YUV 并让 ANativeWindow 显示 YUV。但这里需要硬件和系统的配合YUV buffer 在 ANativeWindow 上并不是每个设备都支持而且像素格式协商极其敏感我记得我调研过一圈像AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420这类格式在部分设备上支持但兼容性远不如 RGBA 稳。如果你追求性能可以考虑 OpenGL ES 渲染 YUV 纹理但这就偏离本文的标题了。6.3 ANativeWindow 的局限性与实际选择最后说点个人感受。ANativeWindow 是在纯 native 环境里最容易上手的渲染路径不需要配置 EGL不需要加载 GLES 库也不涉及 shader一个 lock 加一个 memcpy 就能上屏。对于刚开始在 Android 上接 FFmpeg 的开发者来说ANativeWindow 是打通解码链路耗时最少的手段。不过它也不是万能方案。ANativeWindow 能做的其实就是一个填充像素窗口如果你后续要做复杂的滤镜、倍数缩放、字幕叠加还是需要往 OpenGL ES 走。很多成熟的播放器方案都是 FFmpeg 负责解码输出 YUV 帧然后用 GLES 渲染这样可以最大限度利用硬件加速。从工程角度上看先用 ANativeWindow 跑通整条链路后面再迁移到 GLES 是成本最低的路径因为解码和同步逻辑完全不用改动只需要替换最终渲染层。最后再分享一个小技巧调试 FFmpeg 播放器时建议在 JNI 层把 FFmpeg 的日志回调重定向到 logcat。FFmpeg 默认在 release 构建里会输出大量 debug 信息通过av_log_set_callback把这些信息统一打上自己的 tag排查视频流的分辨率、解码耗时和报错都方便很多。这一段我觉得是除了解码链路之外最值得先去写的代码。本文还有配套的精品资源点击获取
RELATED

相关推荐

从零搭建私有化语音控制中枢:智能家居离线识别与意图解析实战

从零搭建私有化语音控制中枢:智能家居离线识别与意图解析实战

简介:基于树莓派与Python实现的智能家居语音控制系统,面向物联网、嵌入式及语音交互方向的开发者和学生,解决家居场景中用自然语音指令完成门禁、聊天、生活指南、待办提醒等日常操作的问题。压缩包共107个文件,大小仅1.67MB&…

📅 2026/9/9 16:02:26
LED矩阵像素屏制作全攻略:从单片机扫描到反向电压防护

LED矩阵像素屏制作全攻略:从单片机扫描到反向电压防护

简介:围绕像素艺术与LED矩阵结合的入门资源,适合电子爱好者、创客及对复古8位图形感兴趣的开发者。内容系统梳理了像素艺术的设计理念、颜色编码与图像编辑工具的使用方法,涵盖像素艺术编辑软件的操作要点与常见调色板选择建议,并…

📅 2026/9/9 16:02:26
冬季电脑防静电与低温防护全指南:从原理到实操

冬季电脑防静电与低温防护全指南:从原理到实操

冬天一到,我家的电脑就开始“耍脾气”。前几天一个朋友抱着笔记本找我,说开机瞬间屏幕黑掉,电源灯亮着但完全没反应。拿到店里拆开一看,主板南桥芯片上有个焦黑的点,典型的人体静电放电击穿。这事儿在干燥的冬季真不罕…

📅 2026/9/9 15:57:25
MORE NEWS

更多资讯

📰

ML-For-Beginners Notebook 用 pd.read_csv 加载课程数据集报 FileNotFoundError 怎么解决?

ML-For-Beginners Notebook 用 pd.read_csv 加载课程数据集报 FileNotFoundError 怎么解决? 【免费下载链接】ML-For-Beginners 12 weeks, 26 lessons, 52 quizzes, classic Machine Learning for all 项目地址: https://gitcode.com/GitHub_Trending/ml/ML-For-B…

📰

Czkawka 免费清理重复文件与相似图片:Krokiet 完整使用指南,一次扫描找回几十 GB 空间

Czkawka 免费清理重复文件与相似图片:Krokiet 完整使用指南,一次扫描找回几十 GB 空间 【免费下载链接】czkawka Multi functional app to find duplicates, empty folders, similar images etc. 项目地址: https://gitcode.com/GitHub_Trending/cz/cz…

📰

昇腾CANN数据排布与类型全解:从NCHW到NC1HWC0

写这篇博文之前,我先交代一下背景。我最近在调一个昇腾推理项目,模型是PyTorch训练完导出的ONNX,再用ATC工具转成om离线模型。整个过程里,最折磨人的不是模型结构改不好,而是数据和格式对不上:一会儿报shap…

📰

国内 AI 生图工具哪些品牌的功能更全面,设计师商家参考

国内 AI 生图工具哪些品牌的功能更全面,设计师商家参考在国内 AI 创作工具快速发展的当下,设计师与商家在选择平台时,不仅关注图像生成质量,更看重工作流的完整性、内容合规性及多模态协同能力。卓特视觉无限画布作为节点式 AI 创…

📰

Git入门指南:从init到远程协作的核心操作与实战技巧

直接开始。这篇是《Git入门指南》系列的第二篇,上一篇咱们把安装、配置、SSH 这些地基打好了,这一篇就进入正题:日常用 Git 干活最频繁的那批基本操作。从 git init 到 commit,从 diff 到 log,从分支到标签&#xff0c…

📰

KernelSU ksud 实战指南:部署位置、常用命令与排障清单

KernelSU ksud 实战指南:部署位置、常用命令与排障清单 【免费下载链接】KernelSU A Kernel based root solution for Android 项目地址: https://gitcode.com/GitHub_Trending/ke/KernelSU KernelSU 是 Android 平台的内核级 root 方案,其用户空…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬