(Tween 综合讨论) 关于 3.8.4 Tween 系统的类型改进

开发者们,大家好。

我们计划在 3.8.4 版本中对引擎的 Tween 系统做一些强类型的改进。主要涉及如下几点:

1.将 ITweenOption 接口改为模版类 ITweenOption<T>, 受影响的接口为 Tween.to/by。

3.8.4 之前

ITweenOption中定义的回调函数的 target 参数为object`。

在开启 ts 严格模式的情况下,用户实现的回调函数(比如:onUpdate)的 target 参数必须是 object 类型,否则会报如下错误:

tween(this.node)
    .by(1, { scale: new Vec3(2, 2, 2) }, {
        onUpdate: (target?: object, ratio?: number) => { // 只能定义为 object 才不报错
            if (target) {
                const node = target as Node;
                // Do something with node
            }
        },
    }).start();

上述代码中,如果将 target 类型改为 target? : Node,则会报如下错误:

Type '(target?: Node, ratio?: number) => void' is not assignable to type '(target?: object | undefined, ratio?: number | undefined) => void'.
  Types of parameters 'target' and 'target' are incompatible.
    Type 'object | undefined' is not assignable to type 'Node | undefined'.
      Type '{}' is missing the following properties from type 'Node': components, _persistNode, name, uuid, and 124 more.ts(2322)

用户只能通过as将 object 强制转换为 Node 再进行下一步操作,这会导致业务代码比较冗余,as 的类型脱离了 ts 编译器的校验。

计划 3.8.4 开始

我们把 ITweenOption 改为 ITweenOption<T> 模版类型后,相当于 T 类型传递到 ITweenOption,onUpdate 函数的定义由

onUpdate?: (target?: object, ratio?: number) => void;

变为

onUpdate?: (target?: T, ratio?: number) => void;

这样用户代码变为:

tween(this.node)
    .by(1, { scale: new Vec3(2, 2, 2) }, {
        onUpdate: (target?: Node, ratio?: number) => { // 这里要求用户把 target 的类型定义为 Node,否则报错
            if (target) {
                // Do something with target
            }
        },
    }).start();

2.Tween.show/hide/removeSelf/destroySelf 对 target 类型约束的改进

3.8.4 之前

只对这些接口的 API 注释中进行描述,要求用户如果有使用以上接口,target 必须传递 Node 类型,如果用户传递了其它非 Node 类型,这会在运行时报如下错误:

Uncaught TypeError: this.target.getComponentsInChildren is not a function
    at Hide.update (index.js:259626:42)
    at Sequence.update (index.js:260862:23)
    at Sequence.update (index.js:260849:29)

计划 3.8.4 开始

我们对 show/hide/removeSelf/destroySelf 这几个接口的定义做了调整,由原来的

hide (): Tween<T> { ... }

改为

type TweenWithNodeTargetOrUnknown<T> = T extends Node ? Tween<T> : unknown;

hide (): TweenWithNodeTargetOrUnknown<T> { ... }

这样在编码阶段,就能够知道传递了错误的类型,并报如下错误:

Object is of type 'unknown'.ts(2571)
(method) Tween<MyType>.hide(): unknown

3. Tween.call 对 callback 函数的参数类型进行约束,并添加自定义数据参数

3.8.4 之前

Tween.call 的 callback 类型定义为 Function:

// eslint-disable-next-line @typescript-eslint/ban-types 这里还跳过检查。。。
call (callback: Function): Tween<T> { ... }

Tween.call 内部其实是调用 CallFunc 实现的,其实 CallFunc 的构造函数接收 callback, callbackThis 和 data 三个字段。

export class CallFunc extends ActionInstant {
    constructor (callback?: Function, callbackThis?: any, data?: any) { ... }
}

Tween.call 的内部实现只传递了大而全的 Function 类型的 callback 参数,即没有对类型做任何的约束。

另外由于 Tween.call 只传递了 callback 参数,传递给 callbackThis 和 data 的字段其实是可选的,即 undefined。

这样会导致几个问题:

  1. 回调函数没有被约束,可以定义任何样子,任何多个参数,比如 tween.call((a, b, c, d)=>{…})
  2. 上层没法调整回调函数中的 this。当然,可以通过 fn.bind(thisObj) 的方式改,但是多了一个 bind 过程也多一个小开销。
  3. 上层没法透传一些自定义数据给回调函数

计划 3.8.4 开始

针对如上问题,我们修改了 call 的定义,对 callback 类型进行了约束,如下:

export type TCallFuncCallback<TTarget, TData> = (target?: TTarget, data?: TData) => void;

export class Tween<T> {
    ...
    call<TCallbackThis, TData> (
        callback: TCallFuncCallback<T, TData>,
        callbackThis?: TCallbackThis,
        data?: TData): Tween<T> { ... }
}

用户定义的 call 的回调函数,必须是符合此签名的函数类型。例如, target 的类型为 Node,data 的类型是 number,那么回调函数指定定义为:

// 1. 完全无参的回调,实际上回调函数还是有两个参数,分别是 target 和 data,其中 data 为 null
tween(this.node).call( () => {
    // Do something
});

// 2. 带参数的回调,但没传 target 和 data 参数
tween(this.node).call( (target?: Node, data?: number) => {
    // Do something
});

// 3. 带参数的回调,并且传递了 target 和 data 参数
class MyFoo extends Component {
    foo (target?: Node, data?: number): void {
        // 这里 data 为透传的参数 123
    }

    doTest (): void {
        // 可以把 callback 定义为类的成员函数,并且把 this 传递进去保证回调 foo 函数的时候 this 的正确,123 为自定义参数
        // 支持使用这样的用法,可以避免使用闭包函数中间饶一次转
        tween(this.node).call(this.myfoo, this, 123).start();
    }
}

重点来了:兼容性讨论 (已不存在,感谢 @SmallMain 提供方案)

铺垫了这么多,新类型更安全了,但是的确也一定程度破坏了兼容性,因此出这个帖跟大家交流一下。

针对第一点,onUpdate 那个改动 target 的改动,我觉得问题不大,因为用户如果继续用 object 也不会破坏兼容。

针对第二点和第三点,之前的如下这种隐式类型就会报错:

// 示例代码
onLoad () {
    this.tweenParallel = tween(this.node)
        .parallel(
            tween().to(2, { scale: new Vec3(1, 2, 3) }), // 这行会报错,因为 tween() 为 Tween<unknow>, parallel 的签名要求是:(method) Tween<Node>.parallel(...args: Tween<Node>[]): Tween<Node>,不能把 unknown 类型赋值给 Node
        )
}
Argument of type 'Tween<unknown>' is not assignable to parameter of type 'Tween<Node>'.
  The types returned by 'hide()' are incompatible between these types.
    Type 'unknown' is not assignable to type 'Tween<Node>'.ts(2345)

之前没问题,我测试了一下 Tween<unknown> 类型赋值给 Tween<Node> 不会报错:

let t1: Tween<unknown> = tween();
let t2: Tween<Node> = tween(new Node());
t2 = t1;  // 3.8.4 之前不报错

3.8.4 开始会报错:

Type 'Tween<unknown>' is not assignable to type 'Tween<Node>'.ts(2322)
let t2: Tween<Node>

个人觉得改进后的更符合强类型的预期。

上述出问题的示例代码的改造方法其实也很简单:

onLoad () {
    this.tweenParallel = tween(this.node)
        .parallel(
            tween<Node>().to(2, { scale: new Vec3(1, 2, 3) }) // 尽管我们没传 target,这里显式约定 target 的类型是 Node,这样也能约束 { scale: new Vec3(...) } 是符合传递给 Node,比如 scale 如果被错误写为 scale1,那么会报错,这样也能一定程度上避免一些低级错误。
        )
}

因为改进,我们的确破坏了兼容性。之前一些隐式的 tween() 或者 tween({}) 要改为 tween<你的类型>(),因此用户的升级改造成本应该还是相对较低的。

借助此帖,我们倾听广大开发者的建议和意见,如果有更好的解决方案,大家多多交流。谢谢。

2赞

对了,PR 在此:https://github.com/cocos/cocos-engine/pull/16948

欢迎大家 review

  1. Tween 并不支持对非对象进行缓动,类型限制应与运行时行为一致,应该将 T 限制为 object
tween(1).start();   // 不应该被允许
  1. 当前 Tween 对象的设计是不能读取到当前的 target,但是可以设置 target,所以应该是逆变的
// 帖子中提到的代码其实是安全的,不应该报错

let t1: Tween<Animal> = tween(new Animal());
let t2: Tween<Dog> = tween(new Dog());
t2 = t1;    // 3.8.5 后会报错,但这是安全的

t2.target(dog);  // 可以设置 target 为 dog,即使现在是 t1,因为 t1 可以对任何动物类型执行缓动
// 相反,不应该允许这样的赋值:

let t1: Tween<Animal> = tween(new Animal());
let t2: Tween<Dog> = tween(new Dog());
t1 = t2;    // 3.8.5 之前、之后都不会报错,但这是不安全的。

t1.target(animal);  // 现在的 t1 是 t2,而 t2 只能对小狗执行缓动,但现在却可以设置任何动物为缓动目标
  1. 易用性的建议

严格类型会让代码变得更安全,但是像这种并不关心泛型实际类型的情况也比较常见:

class TweenRunner {
    tweens: Tween<any>;

    add(tween: Tween<any>): void;
    remove(tween: Tween<any>): void;
    has(tween: Tween<any>): void;
    ...
}
class Test extends Component {
    _tween: Tween<any>;

    onLoad() {
        this._tween = tween(this.node).start();
    }

}

如果设置默认类型为 any 会简洁许多:

class Tween<T extends object = any>
// 由于 TweenRunner 从设计上就不关心 target,所以不会有问题
class TweenRunner {
    tweens: Tween;

    add(tween: Tween): void;
    remove(tween: Tween): void;
    has(tween: Tween): void;
    ...
}
class Test extends Component {
    _tween: Tween;    // 当然,这可能会在几代人的代码交接中导致问题的出现

    onLoad() {
        this._tween = tween(this.node).start();
    }

}
4赞

看到这个文章我知道了,他们又准备改接口了 :rofl: :rofl: :rofl: :rofl:刚熟悉了第一套接口又来改第二套

为啥要改呢,后续其他地方是不是也会改

不要啊。。。改接口你说我这引擎是升还是不升呢?

点进来之前我还高兴的以为可以传入同node的多组件一起进行缓动了,比如论坛怨声载道的uitransform和opacity。 缓动uitransform然后只能在onUpdate里去用ratio算opacity的设定太反人类了不是吗 :sob:起码我个人是这么认为的,按照逻辑直观的写就应该是
tween(this.node).parallel(
tween(this.node.getComponent(UITransform))).to(XXXXX);
tween(this.node.getComponent(UIOpacity))).to(XXXXX))
.start()

1赞

opacity的tween使用很割裂,color 的tween异常你们有没有打算改?

3.8.3 反馈下,这个我有提issue

感觉挺好的,多了一些类型检测和方便函数调用,改动也不大,只要没bug就问题不大 :100:

我可太支持了,把这个优化了比什么都强

是啊,tween做color渐变颜色会闪

3.8.x系列属于小版本升级,不要修改tween了吧,建议到4.x里面统一改进。

2赞

不建议在小版本改接口,真的非常蛋疼。3.9.x这种大版本再考虑改接口把,别总是让用户蛋疼了 :upside_down_face:

2赞

改之前你们自己体验了吗,是否易用,自己先试试不蹩脚的话可以放出来试试

增加个新的接口,把之前的标注为 deprecated

我也觉得,放到大版本去改合适

这个真的很操蛋。。。
我想一个图片移动的同时渐变消失。。。
™的要分开写。。。
真是醉了

讨论啥,连论坛回复没通知,私信发出去对方收不到的bug都修不了 :roll_eyes:

小版本建议修bug和小优化为主