最右 JS2Flutter 框架——动画、小游戏的实现(四)

660 阅读5分钟

1、概述

动画和小游戏看起来是两个不太相关的话题,但其实它们都依赖于Vsync机制的建立,对动画依赖于Vsync机制不太理解的同学,可以查看Gityuan的博客——深入理解Flutter动画原理[1],最右目前所采用的小游戏引擎是Flame[2],其GameLoop也是借助于Ticker(依赖Vsync)实现Game的不断刷新。可见要实现动画和小游戏,我们必须给Client侧提供Vsync机制。

2、Vsync机制

我们先看看Flutter是如何建立Vsync机制的,在深入理解Flutter动画原理[1]文章中,虽然着重点是在动画流程上,但提到了注册Vsync,比较细心的同学可能会发现文末那张图的Choreographer,Choreographer的作用就是接收底层的Vsync信号,为上层App的渲染提供稳定的时机,这点信息Android同学应该很快能捕捉到。我们再去求证一下,Android端在VsyncWaiter里利用Choreographer给Flutter提供了Vsync时机。

    private final FlutterJNI.AsyncWaitForVsyncDelegate asyncWaitForVsyncDelegate = new FlutterJNI.AsyncWaitForVsyncDelegate() {
        @Override
        public void asyncWaitForVsync(long cookie) {
            Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
                @Override
                public void doFrame(long frameTimeNanos) {
                    float fps = windowManager.getDefaultDisplay().getRefreshRate();
                    long refreshPeriodNanos = (long) (1000000000.0 / fps);
                    FlutterJNI.nativeOnVsync(frameTimeNanos, frameTimeNanos + refreshPeriodNanos, cookie);
                }
            });
        }
    };

而我们是在iOS端,要给Client侧提供Vsync时机,我们去看看iOS端Flutter是如何实现的,iOS端的实现在flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm里面。

- (instancetype)initWithTaskRunner:(fml::RefPtr<fml::TaskRunner>)task_runner
                          callback:(flutter::VsyncWaiter::Callback)callback {
  self = [super init];

  if (self) {
    callback_ = std::move(callback);
    display_link_ = fml::scoped_nsobject<CADisplayLink> {
      [[CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink:)] retain]
    };
    display_link_.get().paused = YES;

    task_runner->PostTask([client = [self retain]]() {
      [client->display_link_.get() addToRunLoop:[NSRunLoop currentRunLoop]
                                        forMode:NSRunLoopCommonModes];
      [client release];
    });
  }

  return self;
}

...

- (void)onDisplayLink:(CADisplayLink*)link {
  fml::TimePoint frame_start_time = fml::TimePoint::Now();
  fml::TimePoint frame_target_time = frame_start_time + fml::TimeDelta::FromSecondsF(link.duration);

  display_link_.get().paused = YES;

  callback_(frame_start_time, frame_target_time);
}

到这里大家应该都明白了,我们也可以用跟系统一样的方式,利用CADisplayLink在Native给Client侧建立起Vsync机制。

3、Animation

JS2Flutter框架是由Client侧去驱动Host侧的渲染的,想要实现UI上的变化基本上都是Client侧的虚拟树发生变化,从而驱动Host侧真实Widget树的变化。很多同学可能会想到,可以在动画插值过程中,通过不断的重建虚拟树去实现动画,但其实这种做法是效率很低的,也没必要,动画只是影响Widget树边界的形变(矩阵变换),并不会引起Widget树结构的变化,所以我们可以只让Host侧真实的Widget做这个动画,Client侧保证动画的值和状态实时更新,保证逻辑上的正确性就可以了。

要让真实的Widget树执行动画,就意味着必须在Host侧构建真实的Animation、AnimationController,在Client侧只是纯粹的Api代理,我们只需要把Client侧创建Animation、AnimationController和Host侧的真身对应起来即可。

AnimationController的构造还依赖于TickerProvider,当Client侧的AnimationController创建时,我们也需要在Host侧创建真身,那真身依赖的TickerProvider该从何而来呢?还记得我们在最右JS2Flutter框架——渲染机制[3]中AppLifecycleState的实现吗?借助AppContainer,由于它的生命周期等于整个Flutter App的生命周期,可以用它来提供可靠的TickerProvider。另一个问题就是保证Client侧Animation的值和状态的准确性,借助我们在上一篇文章最右JS2Flutter框架通信机制[4]中讲述的双向同步通信机制,可以通过监听真实Animation的变化,从而同步修改Client侧Animation。

很多业务场景需要监听Animation的更新去做UI上的变化,在这种使用场景下,难免会带来虚拟树的重建,我们尽可能做更小粒度的Widget树更新。举个例子,我们要实现一个翻卡动画,当动画执行到一半的时候,我们需要将背面显示出来,这种情况我们只做卡片内容的更新。

  Widget build(BuildContext context) {
    final front = widget.childFront;
    final back = widget.childBack;
    Matrix4 transform = Matrix4.identity()..rotateY(_animation.value);
    return AnimatedBuilder(
      animation: _animation,
      builder: (BuildContext context, Widget child) {
        return Transform(
          transform: transform,
          alignment: Alignment.center,
          child: IndexedStack(
            alignment: Alignment.center,
            children: <Widget>[
              front,
              back,
            ],
            index: _animationCtr.value < 0.5 ? 0 : 1,
          ),
        );
      },
    );
  }

4、小游戏

最右目前所采用的小游戏引擎是Flame[2],要实现小游戏的能力,我们必须先对Flame的实现有一定了解,尤其是Flame是如何去绘制的,这里直接抛出结论,有兴趣的同学可以去查看源码,其实核心就在这里:

class GameRenderBox extends RenderBox with WidgetsBindingObserver {
  BuildContext context;
  Game game;
  GameLoop gameLoop;

  GameRenderBox(this.context, this.game) {
    gameLoop = GameLoop(gameLoopCallback);
  }

  ...

  void gameLoopCallback(double dt) {
    if (!attached) {
      return;
    }
    game.recordDt(dt);
    game.update(dt);
    markNeedsPaint();
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    context.canvas.save();
    context.canvas.translate(
        game.builder.offset.dx + offset.dx, game.builder.offset.dy + offset.dy);
    game.render(context.canvas);
    context.canvas.restore();
  }

  ...
}

每次Vsync的时候,会回调gameLoopCallback,每次都会标记刷新,把Game的Component画到Canvas上,Component会确定自己的位置以及所绘制的内容,小游戏的渲染都是通过Canvas去绘制,所以我们先要支持Canvas能力。

我们先看看Flutter是如何实现Canvas的,我们以rotate为例:

void rotate(double radians) native 'Canvas_rotate';

Framework层提供的Canvas,最终实际上调到了Engine层flutter/lib/ui/painting/canvas.cc的同名函数,进而调用SkCanvas的同名函数。我们也采用相同的策略,Client侧声明镜像的Canvas,提供与Flutter Canvas对等的能力,Client侧镜像Canvas函数的调用,直接通过通信渠道转化为Flutter Canvas函数的调用。最右为了实现Canvas的高效绘制,对于Canvas指令的数据化采用StandardMessageCodec去实现。

所以我们只需要按照Flame的思路去实现就好了,当Native通知Client侧Vsync的时候,收集画在Canvas上的指令,然后把这些指令通过StandardMessageCodec数据化,传递到Host侧,再把指令解析出来,还原这些指令操作,让Host侧预占坑的Game绘制到Canvas上即可。

5、结束语

本文主要阐述了JS2Flutter框架Vsync机制的建立,以及Animation和小游戏的实现。综合前面的几篇文章,相信大家对JS2Flutter框架有了更多的了解,希望能对大家有所启发和帮助,最右将在Flutter动态化道路上持续探索,欢迎关注。

6、参考文献

[1]:深入理解Flutter动画原理 gityuan.com/2019/07/13/…

[2]:Flame github.com/flame-engin…

[3]:最右JS2Flutter框架——渲染机制 xie.infoq.cn/article/5c2…

[4]:最右JS2Flutter框架——通信机制 xie.infoq.cn/article/f23…