Android显示系统(09)- SurfaceFlinger的使用

Android显示系统(01)- 架构分析
Android显示系统(02)- OpenGL ES - 概述
Android显示系统(03)- OpenGL ES - GLSurfaceView的使用
Android显示系统(04)- OpenGL ES - Shader绘制三角形
Android显示系统(05)- OpenGL ES - Shader绘制三角形(使用glsl文件)
Android显示系统(06)- OpenGL ES - VBO和EBO和VAO
Android显示系统(07)- OpenGL ES - 纹理Texture
Android显示系统(08)- OpenGL ES - 图片拉伸

一、前言:

之前文章讲解了Android显示系统的架构,并且花了大量时间介绍了OpenGL ES,以及为了保证OpenGL ES的平台无关性,Android引入的EGL。不过我们都是借助于GLSurfaceView来学习的,而我们构建GLSurfaceView对象的时候,在Native层做了很多的事情,其中,大多数逻辑都是SurfaceFlinger辅助我们完成的,它才是整个Android显示系统的核心。

SurfaceFlinger最重要的职责就是合成各个App传递过来的Surface。本文先学会在Native层使用SurfaceFlinger,后续根据代码一点点引入不同知识。

**说明:**后续文章如果出现SF那就是我对SurfaceFlinger的缩写。

二、BootAnimation:

读者可能很郁闷,讲SF的使用,怎么突然蹦出来这么一个东西。其实,BootAnimation是 Android 系统启动时显示的动画,通常包括启动画面、动画效果和加载过程中的图形展示。要学习Native层显示系统,它是最好的程序之一,因为,此时AMS、WMS那些复杂的服务还没有启动,干扰项少!

1、启动:

  • Android.bp当中启动bootanim.rc
  • 解析文件内容启动BootAnimation进程;
  • 启动之后,就会调用BootAnimation的main函数;

这是base\cmds\bootanimation\bootanim.rc内容,我都贴心的加了注释。

shell 复制代码
# 这行定义了一个名为 bootanim 的服务,服务的可执行文件是 /system/bin/bootanimation。
service bootanim /system/bin/bootanimation
    # 这行指定了服务的类别。将其归类为 core 和 animation 意味着该服务属于系统的核心服务及动画服务类别,可能影响其调度和优先级
    class core animation
    # 这行指定了运行该服务的用户身份是 graphics
    user graphics
    # 这行指定该服务属于 graphics 和 audio 组。这样做是为了允许该服务访问所需的资源和设备,因此图形和音频相关的权限会得到支持。
    group graphics audio
    # 这表示该服务在系统启动时不会自动启动。
    disabled
    # 这个关键字指明该服务是一种一次性服务。它在启动后会运行一次并退出,而不是保持在后台持续运行
    oneshot
    # 这行设置了 I/O 调度优先级,rt 表示实时优先级,0 是优先级值。设置为 0 表示它具有较低的实时优先级
    ioprio rt 0
    # 这一行表示将该服务的进程 ID(PID)写入到特定路径 /dev/stune/top-app/tasks 中
    writepid /dev/stune/top-app/tasks

注意,其中这一行disabled表示这个服务不会自己启动,为什么呢?因为它要渲染开机画面,离不开大佬SurfaceFlingerSurfaceFlinger启动之后,会将这个改成enable,然后,bootanimation服务就启动了,启动后当然就要执行main函数了。

2、流程概述:

我们假设自己没有阅读过BootAnimation,但是,我们对SurfaceFlinger比较熟悉,我们觉得应该怎么做?

  • 获得SurfaceFlinger服务;
  • 通过SurfaceFlinger创建Surface;
  • 开始循环:
    • 得到surface里面的buffer;
    • 设置给渲染引擎(比如之前的OpenGL ES),往buffer填充数据;
    • 提交填充好的buffer;

后续我们阅读完开机动画代码之后再总结,只是会详细很多,透过现象看本质,大体思路不会变。

3、main函数:

文件路径:base\cmds\bootanimation\bootanimation_main.cpp

代码如下:

cpp 复制代码
int main()
{
    setpriority(PRIO_PROCESS, 0, ANDROID_PRIORITY_DISPLAY);

    bool noBootAnimation = bootAnimationDisabled();
    ALOGI_IF(noBootAnimation,  "boot animation disabled");
    if (!noBootAnimation) {

        sp<ProcessState> proc(ProcessState::self());
        ProcessState::self()->startThreadPool();

        // create the boot animation object (may take up to 200ms for 2MB zip)
        sp<BootAnimation> boot = new BootAnimation(audioplay::createAnimationCallbacks());

        waitForSurfaceFlinger();

        boot->run("BootAnimation", PRIORITY_DISPLAY);

        ALOGV("Boot animation set up. Joining pool.");

        IPCThreadState::self()->joinThreadPool();
    }
    return 0;
}

总体来看:

负责设置进程的优先级,检查是否禁用 BootAnimation,启动并设置 BootAnimation 相关的状态和资源,同时确保在 SurfaceFlinger 可用后开始播放动画。通过这样的结构,系统能够顺利地处理BootAnimation的显示,确保用户在设备开机时有良好的视觉体验。

详细看看:

  • 设置进程优先级:

    cpp 复制代码
    setpriority(PRIO_PROCESS, 0, ANDROID_PRIORITY_DISPLAY);
    • PRIO_PROCESS 指定要修改的是进程的优先级。
    • 0 表示当前进程(即当前执行的 main 函数)。
    • ANDROID_PRIORITY_DISPLAY 是一个常量,通常是在 Android 中用于设置与显示相关的进程优先级,确保 Boot Animation 在显示时有足够的资源进行渲染。
  • 检查是否禁用 BootAnimation:

    cpp 复制代码
    bool noBootAnimation = bootAnimationDisabled();

    如果没有禁用,才会执行主要逻辑;

  • 打开Binder,并启动线程池:

    因为SF就是一个Binder服务,要想访问SF,必须先打开Binder;

    cpp 复制代码
    sp<ProcessState> proc(ProcessState::self());
    ProcessState::self()->startThreadPool();

    其实ProcessState::self()主要作用就是打开binder,并将进程和线程的相关信息设置进binder的binder_proc,binder_thread结构当中。接着通过startThreadPool()启动线程池,主要处理 IPC(进程间通信)请求。这样可以在后台异步处理工作而不阻塞主线程。

  • 创建 BootAnimation 对象,并设置音频回调函数:

    cpp 复制代码
    sp<BootAnimation> boot = new BootAnimation(audioplay::createAnimationCallbacks());

    这回调函数应该是播放那个开机完了噔噔噔噔的,我没看!!

  • 等待 SurfaceFlinger可用:

    cpp 复制代码
    waitForSurfaceFlinger();
  • 运行BootAnimation

    cpp 复制代码
    boot->run("BootAnimation", PRIORITY_DISPLAY);

    传入"BootAnimation"的名称和显示优先级 PRIORITY_DISPLAY。

  • 加入线程池:

    cpp 复制代码
    IPCThreadState::self()->joinThreadPool();

    当前的线程(也就是main方法所在的线程)加入Binder的线程池,并阻塞住,保证当前进程不退出。

4、启动BootAnimation:

4.1、重要方法:

  • sp指针是Android当中的一个强智能指针(strong pointer,不是smart pointer),对应的还有wp就是weak pointer。
  • BootAnimation继承自ThreadDeathRecipientDeathRecipient继承了RefBase),也就是说BootAnimationRefBase子类(C++没有孙子类的说法,我很认真地告诉你!!!);
  • 其重写了几个关键方法,其在创建其对象并调用run方法后会分别回调到:
    • onFirstRef:继承自RefBase,当对象第一次被sp引用的时候,这个函数就会被调用。
    • readyToRun:继承自Thread,Thread执行前的初始化工作;
    • threadLoop:继承自Thread,每个线程类都要实现的,在这里定义thread的执行内容。会不断循环执行,直到调用了requestExit()退出;
    • binderDied:继承自DeathRecipient,一般当binder异常结束时会调用;

也就是说:BootAnimation的onFirstRef -> readyToRun -> threadLoop方法会相继执行。千万记住这个顺序,Android源代码导出是这个。

4.2、BootAnimation::BootAnimation():

cpp 复制代码
BootAnimation::BootAnimation(sp<Callbacks> callbacks)
        : Thread(false), mClockEnabled(true), mTimeIsAccurate(false),
        mTimeFormat12Hour(false), mTimeCheckThread(nullptr), mCallbacks(callbacks) {
    // SurfaceFlinger在客户端的代理
    mSession = new SurfaceComposerClient();
    // 这个属性用于判断是开机还是关机
    std::string powerCtl = android::base::GetProperty("sys.powerctl", "");
    if (powerCtl.empty()) {
        mShuttingDown = false;
    } else {
        mShuttingDown = true;
    }
    ALOGD("%sAnimationStartTiming start time: %" PRId64 "ms", mShuttingDown ? "Shutdown" : "Boot",
            elapsedRealtime());
}

构造函数中主要是先获取SF在客户端的代理,后面便于和SF进行通信。

4.3、BootAnimation::onFirstRef():

cpp 复制代码
void BootAnimation::onFirstRef() {
    status_t err = mSession->linkToComposerDeath(this);
    if (err == NO_ERROR) {
		// ...
        preloadAnimation();
        // ...
    }
}

主要是通过preloadAnimation()做了预加载开机动画的资源文件,另外监听了SF的死亡通知。

4.4、BootAnimation::readyToRun():

cpp 复制代码
status_t BootAnimation::readyToRun() {
    // 加载启动动画所需的资源(图像、动画等)
    mAssets.addDefaultAssets();
    // 获得主屏幕句柄
    mDisplayToken = SurfaceComposerClient::getInternalDisplayToken();
    if (mDisplayToken == nullptr)
        return -1;

    // 这检索显示器的相关信息,
    // 例如宽度 (dinfo.w)、高度 (dinfo.h) 和其他相关参数。这些信息对于正确设置Surface至关重要。
    DisplayInfo dinfo;
    status_t status = SurfaceComposerClient::getDisplayInfo(mDisplayToken, &dinfo);
    if (status)
        return -1;

    // create the native surface
    // 创建SurfaceControl, 参数指定名称 ("BootAnimation")、宽度、高度和像素格式 (PIXEL_FORMAT_RGB_565)
    sp<SurfaceControl> control = session()->createSurface(String8("BootAnimation"),
            dinfo.w, dinfo.h, PIXEL_FORMAT_RGB_565);

    // 此事务设置Surface的层级,确保其在屏幕上的正确位置。apply() 函数将事务提交到显示系统。
    // 设置Layer(值越大表示在屏幕坐标系Z-order越大,也就是越靠近我们用户)
    SurfaceComposerClient::Transaction t;
    t.setLayer(control, 0x40000000)
        .apply();
    // 获得对应的Surface(画布)
    sp<Surface> s = control->getSurface();

    // initialize opengl and egl // 初始化OpenGL和EGL上下文
    const EGLint attribs[] = {
            EGL_RED_SIZE,   8,
            EGL_GREEN_SIZE, 8,
            EGL_BLUE_SIZE,  8,
            EGL_DEPTH_SIZE, 0,
            EGL_NONE
    };
    EGLint w, h;
    EGLint numConfigs;
    EGLConfig config;
    EGLSurface surface;
    EGLContext context;
    // 获得一块屏幕
    EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);

    eglInitialize(display, nullptr, nullptr);
    // 选择我们自己需要的配置
    eglChooseConfig(display, attribs, &config, 1, &numConfigs);
    // 创建一个EGLSurface,里面包含有普通的Surface,后面OpenGL就会绘制到渲染到这个EGLSurface的Surface当中
    surface = eglCreateWindowSurface(display, config, s.get(), nullptr);
    // 创建一个 EGL 上下文
    context = eglCreateContext(display, config, nullptr, nullptr);
    // 获取surface的宽度
    eglQuerySurface(display, surface, EGL_WIDTH, &w);
    // 获取surface的高度
    eglQuerySurface(display, surface, EGL_HEIGHT, &h);
    // 使创建的上下文成为当前上下文,这将 EGL 上下文、EGLSurface绑定,使后续的 OpenGL 绘制命令作用于此Surface。
    if (eglMakeCurrent(display, surface, surface, context) == EGL_FALSE)
        return NO_INIT;

    mDisplay = display;
    mContext = context;
    mSurface = surface;
    mWidth = w;
    mHeight = h;
    mFlingerSurfaceControl = control;
    mFlingerSurface = s;
    mTargetInset = -1;

    return NO_ERROR;
}

总而言之,readyToRun() 函数设置了启动动画的完整渲染管道:获取显示信息、创建Surface以及初始化 OpenGL ES 和 EGL。

4.5、BootAnimation::threadLoop():

cpp 复制代码
bool BootAnimation::threadLoop()
{
    bool r;
    // We have no bootanimation file, so we use the stock android logo
    // animation.
    if (mZipFileName.isEmpty()) {
        // 系统默认开机动画
        r = android();
    } else {
        // 用户自定义的开机动画
        r = movie();
    }
	// ... 省略一些资源回收的代码
    return r;
}

onFirstRef方法预加载了开机动画文件, 并且将文件地址复制给mZipFileName,因为在编译源码的时候并没有设置开机动画bootanimation.zip所以mZipFileName肯定是空的,因此,我们会走android()函数。

4.6、BootAnimation::android():

主要负责循环在 Android 开机动画中绘制一个 "Android" 图标,并对其进行动态的光效处理(类似 "扫光" 效果)。

主要流程如下:

  1. 加载两个纹理:一个 Android 图标遮罩,一个光条(扫光效果);
  2. 配置 OpenGL 环境,以及裁剪和混合模式;
  3. 在动画循环中,不断更新扫光图像的位置并绘制,同时结合遮罩进行透明处理;
  4. 刷新显示帧并限制帧速率;
  5. 根据退出条件判断是否停止动画;
  6. 最后清理纹理资源。

其视觉效果是一个动态的 Android 图标,有一个扫光效果从左到右滚动,直到系统完成启动时退出动画。。

cpp 复制代码
bool BootAnimation::android()
{
    // 加载纹理资源
    initTexture(&mAndroid[0], mAssets, "images/android-logo-mask.png");
    initTexture(&mAndroid[1], mAssets, "images/android-logo-shine.png");
    mCallbacks->init({});

	// 清屏并设置 OpenGL 状态
    glShadeModel(GL_FLAT);
    glDisable(GL_DITHER);
    glDisable(GL_SCISSOR_TEST);
    glClearColor(0,0,0,1);
    glClear(GL_COLOR_BUFFER_BIT);
    eglSwapBuffers(mDisplay, mSurface);

    glEnable(GL_TEXTURE_2D);
    glTexEnvx(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
	// 计算 Android 图标的位置及绘图区域
    const GLint xc = (mWidth  - mAndroid[0].w) / 2;
    const GLint yc = (mHeight - mAndroid[0].h) / 2;
    // 开启裁剪并设置混合模式
    const Rect updateRect(xc, yc, xc + mAndroid[0].w, yc + mAndroid[0].h);
    glScissor(updateRect.left, mHeight - updateRect.bottom, updateRect.width(),
            updateRect.height());

    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glTexEnvx(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
	// 动画循环处理
    const nsecs_t startTime = systemTime();
    do {
        nsecs_t now = systemTime();
        double time = now - startTime;
        float t = 4.0f * float(time / us2ns(16667)) / mAndroid[1].w;
        GLint offset = (1 - (t - floorf(t))) * mAndroid[1].w;
        GLint x = xc - offset;
        glDisable(GL_SCISSOR_TEST);
        glClear(GL_COLOR_BUFFER_BIT);
		// 绘制与融合
        glEnable(GL_SCISSOR_TEST);
        glDisable(GL_BLEND);
        glBindTexture(GL_TEXTURE_2D, mAndroid[1].name);
        glDrawTexiOES(x,                 yc, 0, mAndroid[1].w, mAndroid[1].h);
        glDrawTexiOES(x + mAndroid[1].w, yc, 0, mAndroid[1].w, mAndroid[1].h);
        glEnable(GL_BLEND);
        glBindTexture(GL_TEXTURE_2D, mAndroid[0].name);
        glDrawTexiOES(xc, yc, 0, mAndroid[0].w, mAndroid[0].h);
        EGLBoolean res = eglSwapBuffers(mDisplay, mSurface);
        if (res == EGL_FALSE)
            break;

        // 限制帧率为12帧
        const nsecs_t sleepTime = 83333 - ns2us(systemTime() - now);
        if (sleepTime > 0)
            usleep(sleepTime);
        // 检查是否退出开机动画
        checkExit();
    } while (!exitPending());
	// 清理资源
    glDeleteTextures(1, &mAndroid[0].name);
    glDeleteTextures(1, &mAndroid[1].name);
    return false;
}

总之,就是不断的使用OpenGL ES给Surface的Buffer填充数据,并提交显示。

流程比较复杂,详细分下:

1. 初始化日志输出

cpp 复制代码
SLOGD("%sAnimationShownTiming start time: %" PRId64 "ms", 
      mShuttingDown ? "Shutdown" : "Boot",
      elapsedRealtime());
  • 使用 SLOGD 输出调试日志,记录动画启动的时间以及是 "开机动画" 还是 "关机动画"(根据 mShuttingDown 判断)。elapsedRealtime() 返回系统的相对启动时间。

2. 加载纹理资源

cpp 复制代码
initTexture(&mAndroid[0], mAssets, "images/android-logo-mask.png");
initTexture(&mAndroid[1], mAssets, "images/android-logo-shine.png");
mCallbacks->init({});
  • mAndroid[0]: 加载 Android 图片遮罩 android-logo-mask.png,此图包含透明处理的 Android 图标。
  • mAndroid[1]: 加载扫光效果用的光条图片 android-logo-shine.png
  • mCallbacks->init({});: 初始化回调函数(可能用于处理动画流程的状态或退出信号)。

3. 清屏并设置 OpenGL 状态

cpp 复制代码
glShadeModel(GL_FLAT);
glDisable(GL_DITHER);
glDisable(GL_SCISSOR_TEST);
glClearColor(0,0,0,1);
glClear(GL_COLOR_BUFFER_BIT);
eglSwapBuffers(mDisplay, mSurface);
glEnable(GL_TEXTURE_2D);
glTexEnvx(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
  • 清空屏幕: 将整个屏幕填充为黑色,并调用 eglSwapBuffers() 立即清空显示缓冲并应用更新。
  • 关掉不必要的功能: 禁用 OpenGL 的光栅化效果(GL_DITHER),裁剪测试功能(GL_SCISSOR_TEST),设置着色模型为平面着色(GL_FLAT)。
  • 启用纹理: 使用 2D 纹理功能,并将纹理绘制模式设置为直接替换(GL_REPLACE),即使用纹理本身的颜色覆盖。

4. 计算 Android 图标的位置及绘图区域

cpp 复制代码
const GLint xc = (mWidth - mAndroid[0].w) / 2;
const GLint yc = (mHeight - mAndroid[0].h) / 2;
const Rect updateRect(xc, yc, xc + mAndroid[0].w, yc + mAndroid[0].h);
  • 计算中心位置: Android 图标作为覆盖物,要将其位置设置在屏幕的正中央 (xc, yc)。
  • 创建裁剪区域: 使用 Rect 定义需要更新的区域,以避免屏幕外的部分被绘制,提高效率。

5. 开启裁剪并设置混合模式

cpp 复制代码
glScissor(updateRect.left, mHeight - updateRect.bottom, updateRect.width(), updateRect.height());
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glTexEnvx(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
  • 开启裁剪测试: 只绘制 updateRect 区域内的内容,其余区域不会被更新。裁剪的 Y 坐标需要上下翻转,因为 OpenGL 的 Y 坐标和 Android 屏幕的 Y 坐标是相反的。
  • 设置混合模式: 使用透明度混合作为处理方式(GL_SRC_ALPHAGL_ONE_MINUS_SRC_ALPHA),用以支持多个透明图片的叠加效果。

6. 动画循环处理

动画入口为以下循环:

cpp 复制代码
do {
    nsecs_t now = systemTime();
    double time = now - startTime;
    float t = 4.0f * float(time / us2ns(16667)) / mAndroid[1].w;
    GLint offset = (1 - (t - floorf(t))) * mAndroid[1].w;

    GLint x = xc - offset;
  • 动画时间计算:
    • startTime: 开始时间(以纳秒为单位)。
    • time: 计算从动画开始经过的时间。
    • t: 利用时间计算扫光的位置比例,并与图片宽度匹配,以此控制扫光图片在 X 方向上的动态偏移量。
  • 偏移计算: 计算当前帧的 X 偏移量 x,使扫光(mAndroid[1])能够动态地从左到右移动。

7. 绘制与融合

cpp 复制代码
// 清屏并禁用裁剪以绘制扫光动画
glDisable(GL_SCISSOR_TEST);
glClear(GL_COLOR_BUFFER_BIT);
glEnable(GL_SCISSOR_TEST);

// 绘制扫光图像
glDisable(GL_BLEND); 
glBindTexture(GL_TEXTURE_2D, mAndroid[1].name);
glDrawTexiOES(x, yc, 0, mAndroid[1].w, mAndroid[1].h);
glDrawTexiOES(x + mAndroid[1].w, yc, 0, mAndroid[1].w, mAndroid[1].h);

// 启用融合并绘制遮罩与图标
glEnable(GL_BLEND);
glBindTexture(GL_TEXTURE_2D, mAndroid[0].name);
glDrawTexiOES(xc, yc, 0, mAndroid[0].w, mAndroid[0].h);

// 交换缓冲区显示动画
EGLBoolean res = eglSwapBuffers(mDisplay, mSurface);
if (res == EGL_FALSE)
    break;
  • 清屏: 清除缓冲区的内容,并重新启用裁剪测试准备绘制动画。
  • 扫光纹理绘制:
    1. 绘制扫光的效果图片(mAndroid[1]),左侧的 x 加偏移量,右偏移则从左侧图片接续平滑过渡。
    2. 动态更新扫光图片的位置(GL_SCISSOR_TEST 限制绘制区域)。
  • 绘制遮罩(图标本体): 将遮罩图片(mAndroid[0])绘制在扫光效果层之上,通过混合模式实现透明区域的显示。
  • 刷新显示: 调用 eglSwapBuffers() 刷新屏幕,使这一帧内容可见。

8. 限制帧速率

cpp 复制代码
const nsecs_t sleepTime = 83333 - ns2us(systemTime() - now);
if (sleepTime > 0)
    usleep(sleepTime);
  • 限制帧速率为 12 FPS,通过计算提前绘制的时间差进行休眠,平衡 CPU 性能消耗。

9. 退出检测

cpp 复制代码
checkExit();
} while (!exitPending());
  • 在每帧渲染完成后调用 checkExit() 检查是否需要退出动画(例如开机完成)。如果 exitPending() 返回 true,退出循环。

10. 清理资源

cpp 复制代码
glDeleteTextures(1, &mAndroid[0].name);
glDeleteTextures(1, &mAndroid[1].name);
  • 删除纹理: 释放掉加载的两个图片纹理。

三、总结:

本文主要以开机画面显示流程介绍了一下一个Naitve的应用程序如何去使用SurfaceFlinger, 但是,遗憾的是,最后的提交buffer部分,收到OpenGL干扰,貌似有点不清楚,后面文章继续深入分析!

相关推荐
yunmoon0114 分钟前
一个功能强大的视频翻译和本地化配音工具,支持影视级双语字幕/视频配音
音视频
Jack1530276827916 分钟前
D2761是为保护扬声器所设计的音频限幅器,其限幅值可通过外接电 阻来调节,适合在个人电脑、便携式音响等系统中作音频限幅用。
电脑·手机·笔记本电脑·音视频·音响
非ban必选17 分钟前
JavaCV视频文件音视频分离
ffmpeg·音视频
JhonKI1 小时前
【MySQL】复合查询
android·数据库·mysql
Yawesh_best2 小时前
MySQL(9)【内置函数】
android·数据库·mysql
小小unicorn10 小时前
[MySQL基础](三)SQL--图形化界面+DML
android·sql·mysql
飞飞-躺着更舒服10 小时前
多媒体文件解复用(Demuxing)过程
计算机视觉·音视频
最 上 川11 小时前
学在西电录播课使用python下载,通过解析m3u8协议、多线程下载ts视频块以及ffmpeg合并
爬虫·python·ffmpeg·音视频
魑魅魍魉952711 小时前
android NumberPicker隐藏分割线或修改颜色
android
ChinaDragonDreamer13 小时前
Flutter:开发环境搭建和Android Studio创建Flutter Project
android·flutter·android studio