Android 消息分发机制解读
前言
想必大家都知道Android系统有自己的一套消息分发机制,,从App启动那一刻起,App就创建了主线程的消息分发实例:Looper.sMainLooper,并开始无限循环,也就是App的心脏,一直跳动,负责协调分配来自各方的事件,让App不断响应用户操作,如果主线程出现了异常,也就是心脏跳动异常停止,那么App的生命随之终止,也就是常见的‘进程已停止运行’。那么,你有没有想过,既然他在一直无限循环,为什么没有卡死呢?为什么能看到“应用无响应”?怎么保证界面刷新不受其他事件影响,怎么做到有条不理的处理每一条消息等等这些问题呢,作为一名Android开发者,我想我们有必要对其结构进行简单了解。
思路整理
基于消息分发机制,我们可以从以下几个方面由深到浅去解惑:
Message;MessageQueue的核心逻辑;Looper的核心逻辑;Handler机制;
在阅读前,你可能需要对数据结构单链表有一定的了解。
源码基于 Android API 33
Message
消息对象,部分源码:
public final class Message implements Parcelable {///*** The targeted delivery time of this message. The time-base is* {@link SystemClock#uptimeMillis}.* @hide Only for use within the tests.*/@UnsupportedAppUsage@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)public long when;//@UnsupportedAppUsage/*package*/ Handler target;//是否为异步消息/*** Returns true if the message is asynchronous, meaning that it is not* subject to {@link Looper} synchronization barriers.** @return True if the message is asynchronous.** @see #setAsynchronous(boolean)*/public boolean isAsynchronous() {return (flags & FLAG_ASYNCHRONOUS) != 0;}
}
关于此类,需要知道的是,我们外部创建的target一般不为空,为空一般是系统内部创建的消息,比如执行View的invalidate()就是发送了target为空的异步消息,具体看 消息队列中的分发逻辑。
MessageQueue 消息队列
顾名思义,是一个存放消息Message的队列,主要负责管理消息的插入和取出。每个消息都有对应的创建时间,插入队列中的消息会按时间排序,当调用next时会从队列中取出一条符合条件的Message,如果没有,则next函数会进入休眠状态,直到被唤醒。为了方便理解,下面对该类的核心方法,核心变量分开分析。
大致结构如下:
//源码位于 android.os.MessageQueue @UnsupportedAppUsage
@SuppressWarnings("unused")
private long mPtr; // used by native code @UnsupportedAppUsage
Message mMessages;boolean enqueueMessage(Message msg, long when){}
Message next() {}
@UnsupportedAppUsage
@TestApi
public int postSyncBarrier(){}@UnsupportedAppUsage
@TestApi
public void removeSyncBarrier(int token) {}
mPtr
看起来就是一个指针,源码中注释为: used by native code,也就是说是在Native层用的代码,我们只需知道他是一道闸,堵塞next方法用的。
mMessages
队列的头部消息。
postSyncBarrier ,removeSyncBarrier
这两个方法是发送和移除同步屏障消息,我们可以把它想象成为一道栅栏,能拦截所有同步消息,只允许异步信息通过。当向队列中插入该消息后,消息分发会主动跳过所有同步消息,即使队列中没有异步消息,直至屏障被移除。
不难发现,调用postSyncBarrier时,会返回一个int类型,也就是屏障的标识,自然移除屏障的时候需要传入这个标识。
至于为什么加入这个机制,大致因为,加入队列中有很多消息,此时用户点击了一下界面,如果没有这个机制,那么响应用户点击的操作需要等待其他事件被分发完后才轮到,容易让用户觉得不流畅,所以系统为了UI相关的消息要优先被分发,在队列中插入同步屏障信号,之后响应点击的消息会被优先处理,处理完成后,再把信号移除,继续执行其他分发。
这个机制如果处理不好,屏障没有正常移除,就会出现进程假死的问题,这也就是官方为何把此方法标记成@UnsupportedAppUsage,不给开发者调用。
*那么这样就可以避免不会出问题吗?*不可能,这只是减少了问题的出现概率,还是有机会出现的,屏障信号是系统在更新UI时发送的,如果我们操作不当,频繁在子线程操作UI,可能某一瞬间,发送了超过两个屏障信号,但是只记录到最后一个token,更新完成后,自然只移除了最后添加的屏障,结果就是之前插入的一直挂在队列中,堵塞主线程所有同步消息,也就引发了同步屏障泄露 的问题,App就直接出现了明明有些控件明明还在刷新,但是怎么点都没反应。
这也是为什么官方只允许我们只能在主线程执行更新UI操作原因之一。
enqueueMessage
分发消息,把用户发的Message插入到队列中。关键源码:
boolean enqueueMessage(Message msg, long when){//target为空为屏障消息,屏障信息只能由系统代码发送,用户端发送的消息target不可能是空//这里就解析了为什么用户不能发送没有target的消息if (msg.target == null) {// throw new IllegalArgumentException("Message must have a target.");}//......省略synchronized (this) {//......省略//1.把当前的对头赋值给pMessage p = mMessages;boolean needWake;//是否需要唤醒next函数if (p == null || when == 0 || when < p.when) { //2.A 如果对头为空(队列没有任何消息)或者when为0(只有刚开机才会存在)或者msg的时间比队头的时间早//把待分发消息插到对头// New head, wake up the event queue if blocked. msg.next = p; //把当前消息的next指向p 尽管p为空mMessages = msg; //把当前消息放到对头needWake = mBlocked;//?} else { //2.B 队列中已有消息,这时需要把当前消息按时间顺序插到队列中// Inserted within the middle of the queue. Usually we don't have to wake // up the event queue unless there is a barrier at the head of the queue // and the message is the earliest asynchronous message in the queue. //3、是否需要唤醒(正在堵塞、队头是屏障信号、当前消息是异步消息 )needWake = mBlocked && p.target == null && msg.isAsynchronous(); Message prev; for (;;) { //4、开始循环队列,和队列中消息when做比较,找出当前消息的when值在队列中最适合的位置prev = p; //此时p的角色为当前遍历的消息,先赋值给上一条消息,保证每次循环能拿到上一条消息和下一条消息,方便插入p = p.next; //取下一条消息,赋值到pif (p == null || when < p.when) { //5、如果下一条消息是空(说明已到达队尾),或者当前消息的时刻比下一条消息的时刻小,说明此时的位置最适合,结束循环break; }//队头符合步骤3处条件,说明有同步屏障信号,并且当前p是异步消息,根据步骤5,能走到这里说消息分发时机还没到,所以不需要唤醒nextif (needWake && p.isAsynchronous()) { needWake = false; } } //把当前消息入队,插入到上一条消息和p之间msg.next = p; // invariant: p == prev.next prev.next = msg;}// We can assume mPtr != 0 because mQuitting is false.if (needWake) { //唤醒next函数,取 消息 分发nativeWake(mPtr);}}return true;
}
next 取消息
从队列中取出一条可用消息(when时机合适),如果有就返回,没有则堵塞,直到mPtr被唤醒。
@UnsupportedAppUsageMessage next() {//......省略//1、下一次唤醒时间int nextPollTimeoutMillis = 0;for (;;) {//2、开始死循环取消息if (nextPollTimeoutMillis != 0) {Binder.flushPendingCommands();}//3、堵塞,时长为nextPollTimeoutMillis,或者主动调用唤醒函数`nativeWake`nativePollOnce(ptr, nextPollTimeoutMillis);//被唤醒后,开始取消息synchronized (this) {//同步锁,保证线程安全// Try to retrieve the next message. Return if found.//记下当前系统时间,后面判断消息是否达到时间条件final long now = SystemClock.uptimeMillis();Message prevMsg = null;Message msg = mMessages;if (msg != null && msg.target == null) {//4、关键点,target为空,说明当前队头消息为屏障信号,需要优先处理离信号最近的异步消息// Stalled by a barrier. Find the next asynchronous message in the queue.do {//一直在队列中寻找离信号最近的异步消息,直到没找到或者到达队尾prevMsg = msg;msg = msg.next;//如果一直找到队尾,msg.next是空,结束循环} while (msg != null && !msg.isAsynchronous());}if (msg != null) {//5、此时,如果有屏障信号的话,步骤4肯定走了,msg不为空肯定是异步消息,否则msg必为空//检查该消息是否以达到分发时机if (now < msg.when) {//当前时间还没达到消息的时机,计算还差多久,赋值给nextPollTimeoutMillis,进入下一次堵塞,直到达到时间nextPollTimeoutMillis,再取该消息// Next message is not ready. Set a timeout to wake up when it is ready.nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);} else {//已达到取消息时机// Got a message.mBlocked = false;//把消息出队if (prevMsg != null) {//说明此消息在队列里面(同步屏障会优先执行里面的异步消息),要把消息出队,把该消息的上一条消息的next直接指向该消息的下一条消息prevMsg.next = msg.next;} else {//说明此消息是队头,直接把当前消息的next放到队头mMessages = msg.next;}//删除出队消息的next指向msg.next = null;if (DEBUG) Log.v(TAG, "Returning message: " + msg);//标注消息正在使用msg.markInUse();//把该消息返回return msg;}} else {//6、没有符合条件消息,继续下一次循环,取符合条件的消息//如果屏障信号没有被移除,又没有异步消息进入队列,那么next函数将陷入死循环,循环线路 3--》4--》6//导致APP所有同步消息无法被处理,表现为软件部分界面卡死(如文本正常刷新,点击事件无法响应)并且不会引发ANR// No more messages.nextPollTimeoutMillis = -1;}// Process the quit message now that all pending messages have been handled.if (mQuitting) {dispose();return null;}// If first time idle, then get the number of idlers to run.// Idle handles only run if the queue is empty or if the first message// in the queue (possibly a barrier) is due to be handled in the future.if (pendingIdleHandlerCount < 0&& (mMessages == null || now < mMessages.when)) {pendingIdleHandlerCount = mIdleHandlers.size();}if (pendingIdleHandlerCount <= 0) {// No idle handlers to run. Loop and wait some more.mBlocked = true;continue;}if (mPendingIdleHandlers == null) {mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];}mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);}// Run the idle handlers.// We only ever reach this code block during the first iteration.for (int i = 0; i < pendingIdleHandlerCount; i++) {final IdleHandler idler = mPendingIdleHandlers[i];mPendingIdleHandlers[i] = null; // release the reference to the handlerboolean keep = false;try {keep = idler.queueIdle();} catch (Throwable t) {Log.wtf(TAG, "IdleHandler threw exception", t);}if (!keep) {synchronized (this) {mIdleHandlers.remove(idler);}}}// Reset the idle handler count to 0 so we do not run them again.pendingIdleHandlerCount = 0;// While calling an idle handler, a new message could have been delivered// so go back and look again for a pending message without waiting.nextPollTimeoutMillis = 0;}}
Looper
一个无限循环的类,他内部持有消息队列MessageQueue的引用,当Looper.loop()后,将会一直调用MessageQueue.next函数获取消息,next在没有消息的时候又会堵塞,让出CPU资源,这就是为什么死循环却没有占满CPU的原因。关键源码:
public final class Looper {public static void prepare() {prepare(true);}public static Looper getMainLooper() {synchronized (Looper.class) {return sMainLooper;}}/*** 循环一次的逻辑* Poll and deliver single message, return true if the outer loop should continue.*/@SuppressWarnings("AndroidFrameworkBinderIdentity")private static boolean loopOnce(final Looper me,final long ident, final int thresholdOverride) {//从队列中取一条消息Message msg = me.mQueue.next(); // might blockif (msg == null) {//由于next没消息会堵塞,所以没消息的时候这里不会执行,除非强制退出// No message indicates that the message queue is quitting.return false;}try {//开始分发取到的消息msg.target.dispatchMessage(msg);if (observer != null) {observer.messageDispatched(token, msg);}dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;} catch (Exception exception) {if (observer != null) {observer.dispatchingThrewException(token, msg, exception);}throw exception;} finally {ThreadLocalWorkSource.restore(origWorkSource);if (traceTag != 0) {Trace.traceEnd(traceTag);}}//省略,,,,,,,,,,,,,,,,,,,,,,,// Make sure that during the course of dispatching the// identity of the thread wasn't corrupted.final long newIdent = Binder.clearCallingIdentity();if (ident != newIdent) {Log.wtf(TAG, "Thread identity changed from 0x"+ Long.toHexString(ident) + " to 0x"+ Long.toHexString(newIdent) + " while dispatching to "+ msg.target.getClass().getName() + " "+ msg.callback + " what=" + msg.what);}msg.recycleUnchecked();return true;}/*** 开始无限循环* Run the message queue in this thread. Be sure to call* {@link #quit()} to end the loop.*/@SuppressWarnings("AndroidFrameworkBinderIdentity")public static void loop() {final Looper me = myLooper();if (me == null) {throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");}if (me.mInLoop) {Slog.w(TAG, "Loop again would have the queued messages be executed"+ " before this one completed.");}me.mInLoop = true;// Make sure the identity of this thread is that of the local process,// and keep track of what that identity token actually is.Binder.clearCallingIdentity();final long ident = Binder.clearCallingIdentity();// Allow overriding a threshold with a system prop. e.g.// adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'final int thresholdOverride =SystemProperties.getInt("log.looper."+ Process.myUid() + "."+ Thread.currentThread().getName()+ ".slow", 0);me.mSlowDeliveryDetected = false;for (;;) {//无限循环核心if (!loopOnce(me, ident, thresholdOverride)) {return;}}}
}
Handler
通过Handler,我们可以把消息发送到对应的消息队列中,是用户代码操作消息队列的入口。关键源码:
public class Handler {/*** Use the provided {@link Looper} instead of the default one.** @param looper The looper, must not be null.*/public Handler(@NonNull Looper looper) {this(looper, null, false);}/*** Use the provided {@link Looper} instead of the default one and take a callback* interface in which to handle messages.** @param looper The looper, must not be null.* @param callback The callback interface in which to handle messages, or null.*/public Handler(@NonNull Looper looper, @Nullable Callback callback) {this(looper, callback, false);}
}
上面只列举了几个构造函数,其他一些post消息函数就不一一说明了,主要是Handler创建需要关联一个Looper,发消息的时候又调用了Looper 内部的消息队列的分发消息函数,把消息插入到队列中,完成用户对消息队列的操作。
总结
至此,相信你对消息分发机制也有大概的理解。
相关文章:
Android 消息分发机制解读
前言 想必大家都知道Android系统有自己的一套消息分发机制,,从App启动那一刻起,App就创建了主线程的消息分发实例:Looper.sMainLooper,并开始无限循环,也就是App的心脏,一直跳动,负责协调分配来…...
【ML】LSTM应用——预测股票(基于 tensorflow2)
LSTM 应用预测股票数据 所用数据集:https://www.kaggle.com/datasets/yuanheqiuye/bank-stock 基于:tensorFlow 2.x 数据处理 import numpy as np import pandas as pd from matplotlib import pyplot as plt from sklearn.model_selection import tr…...
汇编语言程序设计实验报告
一、实验一 1、实验内容 (1)用Debug命令查看寄存器和内存中的内容 (2)上机过程及程序调试 2、实验目的 (1)要求掌握使用Debug命令查看寄存器和内存的方法; (2)通过…...
广域网(WAN)设备通信过程(通信流程、通信步骤、通信顺序、设备通信、主机通信)(MAC地址在本地链路中的作用)跳跃(hop)
文章目录 广域网(WAN)通信:MAC地址在本地链路中的作用引言MAC地址概述什么是MAC地址?如何工作? MAC地址与广域网MAC地址的局限性IP地址和路由 广域网设备通信过程1. 请求生成2. 封装数据帧3. 确定下一跳4. 数据传输5. …...
ExoPlayer架构详解与源码分析(10)——H264Reader
系列文章目录 ExoPlayer架构详解与源码分析(1)——前言 ExoPlayer架构详解与源码分析(2)——Player ExoPlayer架构详解与源码分析(3)——Timeline ExoPlayer架构详解与源码分析(4)—…...
智能优化算法应用:基于粒子群算法3D无线传感器网络(WSN)覆盖优化 - 附代码
智能优化算法应用:基于粒子群算法3D无线传感器网络(WSN)覆盖优化 - 附代码 文章目录 智能优化算法应用:基于粒子群算法3D无线传感器网络(WSN)覆盖优化 - 附代码1.无线传感网络节点模型2.覆盖数学模型及分析3.粒子群算法4.实验参数设定5.算法结果6.参考文…...
微积分-序言
大家好,这里我将为大家带来一个全新的专栏“微积分”。在这里我将为大家讲解微积分的内容,我会从最基础的内容开始讲解。争取让零基础的人也可以看懂和学会。 我也会在后续出一些微积分的题,让大家可以进行巩固和提高。 学习微积分那么就需要…...
ArchLinux安装详细步骤
下载(略)安装VirtualBox(略)新建虚拟机(略)启动 进入提示符 进入安装设置界面 archinstall出现界面: 逐项设置。 Disk我选择了ext4 在Profile中 我选择了KDE作为桌面(选择后按回车…...
react 学习笔记 李立超老师 | (学习中~)
文章目录 react学习笔记01入门概述React 基础案例HelloWorld三个API介绍 JSXJSX 解构数组 创建react项目(手动)创建React项目(自动) | create-react-app事件处理React中的CSS样式内联样式 | 内联样式中使用state (不建议使用)外部样式表 | CSS Module React组件函数式组件和类组…...
Docker镜像和容器的简单操作
1.镜像管理 搜索镜像: 这种方法只能用于官方镜像库 搜索基于 centos 操作系统的镜像 # docker search centos 按星级搜索镜像: 查找 star 数至少为 100 的镜像,默认不加 s 选项找出所有相关 ubuntu 镜像…...
章鱼网络进展月报 | 2023.11.1-11.30
章鱼网络大事摘要 1、2023年12月,Octopus 2.0 将会正式启动。 2、隐私协议 Secret Network 宣布使用 Octopus Network 构建的 NEAR-IBC 连接 NEAR 生态。 3、Louis 受邀作为嘉宾,在 NEARCON2023 的多链网络主题沙龙中发言:我们依然处于区…...
基于Maven构建OSGI应用(Maven和OSGI结合)
基于Maven构建OSGI应用。 使用Maven来构建项目,包括项目的创建、子模块buldle的创建等。使用OSGI来实现动态模块化管理,实现模块的热插拔效果(即插即用)。 创建一个Maven项目:helloworld,并在该项目下创建…...
oracle分组排序后取第一条
在 Oracle 中,可以使用「ROW_NUMBER」函数对某个列进行分组并排序,然后通过「WHERE」语句取第一条记录。 假设有一张「USERS」表,其中包含「ID」、「NAME」、「AGE」和「COUNTRY」列,您可以使用以下 SQL 语句对「AGE」列进行分组…...
MAMBA介绍:一种新的可能超过Transformer的AI架构
有人说,“理解了人类的语言,就理解了世界”。一直以来,人工智能领域的学者和工程师们都试图让机器学习人类的语言和说话方式,但进展始终不大。因为人类的语言太复杂,太多样,而组成它背后的机制,…...
win系统一台电脑安装两个不同版本的mysql教程
文章目录 1.mysql下载zip包(地址)2.解压在你的电脑上(不要再C盘和带中文的路径)3.创建my.ini文件4.更改环境变量(方便使用, 可选)5.打包mysql服务6.初始化mysql的data7.启动刚刚打包的服务8.更改密码 1.mys…...
esp32-s3部署yolox_nano进行目标检测
ESP32-S3部署yolox_nano进行目标检测 一、生成模型部署项目01 环境02 配置TVM包03 模型量化3.1预处理3.2 量化 04 生成项目 二、烧录程序 手上的是ESP32-S3-WROOM-1 N8R8芯片,整个链路跑通了,但是识别速度太慢了,20秒一张图,所以暂…...
TCP传输数据的确认机制
实际的TCP收发数据的过程是双向的。 TCP采用这样的方式确认对方是否收到了数据,在得到对方确认之前,发送过的包都会保存在发送缓冲区中。如果对方没有返回某些包对应的ACK号,那么就重新发送这些包。 这一机制非常强大。通过这一机制…...
使用Ansible Expect模块实现自动化交互式任务
Ansible是一种功能强大的自动化工具,可用于自动化配置管理、部署和任务执行。其中的Expect模块是Ansible的一个重要组件,它允许我们自动化处理需要与交互式命令行进行交互的任务。本文将介绍如何使用Ansible的Expect模块,并提供一些示例来说明…...
51单片机独立按键以及矩阵按键的使用以及其原理--独立按键 K1 控制 D1 指示灯亮灭以及数码管显示矩阵按键 S1-S16 按下后键值 0-F
IO 的使用–按键 本文主要涉及8051单片机按键的使用,包括独立按键以及矩阵按键的使用以及其原理,其中代码实例包括: 1.独立按键 K1 控制 D1 指示灯亮灭 2.通过数码管显示矩阵按键 S1-S16 按下后键值 0-F 文章目录 IO 的使用--按键一、按键消抖二、独立按…...
chrome安装jsonview
写在前面 通过jsonview可以实现,当http响应时application/json时直接在浏览器格式化显示,增加可读性。本文看下如何安装该插件到chrome中。 1:安装 首先在这里 下载插件包,然后解压备用。接着在chrome按照如下步骤操作…...
ES6从入门到精通:前言
ES6简介 ES6(ECMAScript 2015)是JavaScript语言的重大更新,引入了许多新特性,包括语法糖、新数据类型、模块化支持等,显著提升了开发效率和代码可维护性。 核心知识点概览 变量声明 let 和 const 取代 var…...
【HarmonyOS 5.0】DevEco Testing:鸿蒙应用质量保障的终极武器
——全方位测试解决方案与代码实战 一、工具定位与核心能力 DevEco Testing是HarmonyOS官方推出的一体化测试平台,覆盖应用全生命周期测试需求,主要提供五大核心能力: 测试类型检测目标关键指标功能体验基…...
java调用dll出现unsatisfiedLinkError以及JNA和JNI的区别
UnsatisfiedLinkError 在对接硬件设备中,我们会遇到使用 java 调用 dll文件 的情况,此时大概率出现UnsatisfiedLinkError链接错误,原因可能有如下几种 类名错误包名错误方法名参数错误使用 JNI 协议调用,结果 dll 未实现 JNI 协…...
Objective-C常用命名规范总结
【OC】常用命名规范总结 文章目录 【OC】常用命名规范总结1.类名(Class Name)2.协议名(Protocol Name)3.方法名(Method Name)4.属性名(Property Name)5.局部变量/实例变量(Local / Instance Variables&…...
学校招生小程序源码介绍
基于ThinkPHPFastAdminUniApp开发的学校招生小程序源码,专为学校招生场景量身打造,功能实用且操作便捷。 从技术架构来看,ThinkPHP提供稳定可靠的后台服务,FastAdmin加速开发流程,UniApp则保障小程序在多端有良好的兼…...
SpringBoot+uniapp 的 Champion 俱乐部微信小程序设计与实现,论文初版实现
摘要 本论文旨在设计并实现基于 SpringBoot 和 uniapp 的 Champion 俱乐部微信小程序,以满足俱乐部线上活动推广、会员管理、社交互动等需求。通过 SpringBoot 搭建后端服务,提供稳定高效的数据处理与业务逻辑支持;利用 uniapp 实现跨平台前…...
现代密码学 | 椭圆曲线密码学—附py代码
Elliptic Curve Cryptography 椭圆曲线密码学(ECC)是一种基于有限域上椭圆曲线数学特性的公钥加密技术。其核心原理涉及椭圆曲线的代数性质、离散对数问题以及有限域上的运算。 椭圆曲线密码学是多种数字签名算法的基础,例如椭圆曲线数字签…...
什么是Ansible Jinja2
理解 Ansible Jinja2 模板 Ansible 是一款功能强大的开源自动化工具,可让您无缝地管理和配置系统。Ansible 的一大亮点是它使用 Jinja2 模板,允许您根据变量数据动态生成文件、配置设置和脚本。本文将向您介绍 Ansible 中的 Jinja2 模板,并通…...
Python 包管理器 uv 介绍
Python 包管理器 uv 全面介绍 uv 是由 Astral(热门工具 Ruff 的开发者)推出的下一代高性能 Python 包管理器和构建工具,用 Rust 编写。它旨在解决传统工具(如 pip、virtualenv、pip-tools)的性能瓶颈,同时…...
【生成模型】视频生成论文调研
工作清单 上游应用方向:控制、速度、时长、高动态、多主体驱动 类型工作基础模型WAN / WAN-VACE / HunyuanVideo控制条件轨迹控制ATI~镜头控制ReCamMaster~多主体驱动Phantom~音频驱动Let Them Talk: Audio-Driven Multi-Person Conversational Video Generation速…...
