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干扰,貌似有点不清楚,后面文章继续深入分析!

相关推荐
Kapaseker3 小时前
你不看会后悔的2025年终总结
android·kotlin
alexhilton6 小时前
务实的模块化:连接模块(wiring modules)的妙用
android·kotlin·android jetpack
专业开发者7 小时前
2020 年国际消费电子展上,蓝牙 ® 音频迎来重磅升级
音视频
ji_shuke7 小时前
opencv-mobile 和 ncnn-android 环境配置
android·前端·javascript·人工智能·opencv
专业开发者7 小时前
蓝牙低功耗音频(Bluetooth LE Audio)最具颠覆性的新功能,当属广播音频
音视频
sunnyday04269 小时前
Spring Boot 项目中使用 Dynamic Datasource 实现多数据源管理
android·spring boot·后端
幽络源小助理10 小时前
下载安装AndroidStudio配置Gradle运行第一个kotlin程序
android·开发语言·kotlin
inBuilder低代码平台10 小时前
浅谈安卓Webview从初级到高级应用
android·java·webview
豌豆学姐10 小时前
Sora2 短剧视频创作中如何保持人物一致性?角色创建接口教程
android·java·aigc·php·音视频·uniapp
白熊小北极10 小时前
Android Jetpack Compose折叠屏感知与适配
android