Flutter Web:Shadow Root问题

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情

document.getElementById找不到节点

在flutter1.x版本的dev分支上可以使用flutter web,但是我们在使用第三方js sdk的时候会出现问题,比如AgoraRtc、lottie等。

问题都是出现在document.getElementById,因为这些sdk中或者使用的时候需要通过这个方法获取节点来操作,比如lottie,我们封装的代码如下:

...
class LottieWidget extends StatefulWidget{
  String path;
  bool isLoop;
  bool isAutoPlay;
  double width;
  double height;
  ...

  LottieWidget(this.path, this.width, this.height, this.isAutoPlay, this.isLoop, this._animationListener);
  @override
  State<StatefulWidget> createState() {
    return _lottieWidget;
  }

  void lottiePlay() {
    _lottieWidget.lottiePlay();
  }

  void lottieStop() {
    _animationListener = null;
    _lottieWidget.lottieStop();
  }
}

class _LottieWidget extends State<LottieWidget> {

  @override
  Widget build(BuildContext context) {
    js.context["lottieLoaded"] = lottieLoaded;

    DivElement divElement = DivElement();
    divElement.id = "lottie_anim";

    StyleElement styleElement = StyleElement();
    styleElement.type = "text/css";
    styleElement.innerHtml = """
          html,
          body {
          }
          """;
    divElement.append(styleElement);

    var script = """
    var lottieAnim = document.getElementById("lottie_anim");
    var lottieObj = lottie.loadAnimation({
    container:lottieAnim,
    renderer: 'svg',
    loop:${widget.isLoop},
    autoplay:${widget.isAutoPlay},
    path:"assets/${widget.path}"
    });
    
    // 动画播放完成触发
    lottieObj.addEventListener('complete', lottieLoaded);
    
    var lottiePlay = function(){
      lottieObj.play();
    }
    
    var lottiePause = function(){
        lottieObj.pause();       
    }
    
    var lottieStop = function() {
        lottieObj.stop();
    }
    """;
    ScriptElement scriptElement = new ScriptElement();
    scriptElement.innerHtml = script;
    divElement.append(scriptElement);

    String _divId = "lottieanim" + DateTime.now().toIso8601String();
    ui.platformViewRegistry.registerViewFactory(
      _divId,
      (int viewId) => divElement,
    );
    Widget _iframeWidget = HtmlElementView(
      key: UniqueKey(),
      viewType: _divId,
    );

    return SizedBox(child: _iframeWidget, width: widget.width, height: widget.height,);
  }

  void lottiePlay() {
    js.context.callMethod("lottiePlay");
  }

  void lottieStop() {
    js.context.callMethod("lottieStop");
  }

  void lottiePause() {
    js.context.callMethod("lottiePause");
  }

  // 动画播放完成触发
  void lottieLoaded() {
    print("loaded");
    widget._animationListener?.call();
  }

  @override
  void dispose() {
    super.dispose();
    lottieStop();
    widget._animationListener = null;
  }
}
复制代码

可以看到我们将一个id为lottie_anim的div添加到页面中,然后在js代码中通过document.getElementById获取这个节点,并设置到lottie中,这样lottie的sdk中就会在这个div上绘制动画。

但是执行的时候发现动画根本没显示,而且没有报错。通过在js中打印日志逐行测试发现document.getElementById("lottie_anim")获取到的是null。但是为什么是null的呢?

我们运行后打开chrome开发者工具,在Elements栏中查找lottie_anim,发现可以找到,但是它的位置如下: 1619693901810.jpg

可以看到这个div是在一个shadow-root下。

那么这个是做什么用的?

Shadow Dom

shadow dom简单来说就是封装,就是将一个组件封装起来,同时设置了隔离,外界无法访问内部的节点。比如video,我们使用的时候非常简单:

<video src="" id='test'></video>
复制代码

但是当我们打开开发者工具,在设置中将show user agent shadow DOM选中后,在回头看Elements中的节点,就会发现video下面存在一个shadow-root,在下面有很多节点,包括播放按钮、播放时长、进度条等等。

关于Shadow DOM,我参考的是quanzhan.applemei.com/webStack/Tk…

正是因为Shadow DOM隐藏的这种特性,导致了上面的问题。因为在flutter中,我们用HtmlElementView来展示html组件,这些组件都会被放在Shadow DOM中,所以导致了在js中通过document.getElementById获取的都是null,也就导致了很多第三方sdk无法正常使用。

解决问题

我们发现了问题,但是如何去解决呢?

其实可以获取Shadow DOM中的节点,只不过要复杂一点。首先我们看上面的节点信息,在Shadow DOM外一层是一个flt-platform-view的节点,这个我们是可以直接获取到的,通过getElementsByTagName,因为页面上可以会有多个flt-platform-view,所以这是一个array,,如下:

1619694911525.jpg

我们这里其实只有一个flt-platform-view,所以array里只有一个flt-platform-view节点。然后我们获取它的shadowRoot就可以得到Shadow节点,再通过getElementById来获取我们需要的节点即可,如下:

var sroot = document.getElementsByTagName("flt-platform-view")[0].shadowRoot;
var lootiedom = sroot.getElementById('lottie_anim');
复制代码

这样就可以得到相应的节点,替换上面js第一行代码就可以正常显示了。

其实在网上也有很多人遇到了同样的问题,比如blog.csdn.net/thunder_sz/… ,官方也创建了一个对应的issues:github.com/flutter/flu…

里面有人提到了通过slot来解决这个问题,目前我还没有研究明白怎么处理。另外还提到了在flutter2.0上已经解决了该问题,下面我们来聊聊。

Flutter2.0上的Shadow DOM

其实issues也说了,在flutter2.0上只有Canvas Kit解决了这个问题。那么这又是什么?

之前我们在解决image跨域的问题时提到过,flutter有两种渲染模式:CanvasKit和Html(详细介绍见【flutter web:网络图片(圆形图片)、HTML renderer(解决大量图片卡)】)。

在flutter2.0之后,在浏览器中默认使用的就是CanvasKit这种渲染模式,而这种模式就不存在Shadow DOM的问题。运行后节点如下:

1619696122700.jpg

可以看到整体结构变化了,没有了Shadow DOM,所以可以直接获取到该节点,这样就不存在问题了。

但是这种模式下存在Image加载网络图片跨域的问题(同样见上面提到的文章),官方给出的解决方案是用html来代替Image,通过图片过多时要使用html render。这样就又回到了之前的问题上了,还需要通过上面的处理来解决。

其实只要解决了Image跨域的问题,还是建议最好使用Canvas Kit来渲染,因为Html Render存在不少问题,比如在debug下不停的打印日志导致非常卡等问题。

同时加载多个HtmlElementView导致失效

因为在项目中,我们可能会同时显示两个HtmlElementView,比如在直播过程(AgoraRtc)中显示动画(lottie),这样如果通过上面的处理就会出现问题。因为上面我们得到的array中都取第一个,这样其实第二个获取的节点是错误的。但是我们又无法确定哪个是第几个,怎么处理?

目前我想到的方法就是遍历,因为我们设置的每个div的id是不同的,所以通过遍历来找到一个即可,代码如下:

var lottieAnim = document.getElementById("lottie_anim");  //渲染模式如果是html,则不能直接这么获取;如果是canvas则可以
var roots = document.getElementsByTagName("flt-platform-view");
  for(var i = 0; i < roots.length; i++){
    var tmp = roots[i].shadowRoot.getElementById("lottie_anim");
    if(tmp){
      lottieAnim = tmp;
    }
  }
复制代码

这里第一步先正常获取,如果获取不到再通过flt-platform-view获取,并遍历找到节点即可。

AgoraRtc和aliplayer的处理

lottie处理起来比较简单,因为获取节点的操作是我们自己的代码,但是其他sdk就不一样了,比如AgoraRtc和aliplayer是在sdk内部获取节点的,这样我们就需要修改他们的sdk。

AgoraRtc的代码大约在4000多行,如下:

    	void 0 !== t.elementID ? (document.getElementById(t.elementID).appendChild(t.div), t.container = document.getElementById(t.elementID)) : (document.body.appendChild(t.div), t.container = document.body),
            t.parentNode = t.div.parentNode;
            var a = {
                video: {
                    playerId: t.playerId,
                    stateId: 0,
                    playDeferTimeout: null,
                    error: !1,
                    status: "init",
                    reason: null,
                    updatedAt: Date.now()
                },
                audio: {
                    playerId: t.playerId,
                    stateId: 0,
                    playDeferTimeout: null,
                    error: !1,
                    status: "init",
                    reason: null,
                    updatedAt: Date.now()
                }
            };
复制代码

这里document.getElementById(t.elementID)就无法正常获取,导致进入房间且已经订阅成功,但是一直不显示直播流。

通过上面的方法处理一下即可,如下:

            var element = document.getElementsByTagName("flt-platform-view")[0].shadowRoot;
            void 0 !== t.elementID ? (element.getElementById(t.elementID).appendChild(t.div),
            t.container = element.getElementById(t.elementID)) : (document.body.appendChild(t.div),
            t.container = document.body),
            t.parentNode = t.div.parentNode;
            console.log(t.elementID);
            var a = {
                video: {
                   ..
                },
                audio: {
                    ...
                }
            };
复制代码

这里没有遍历,因为我们进入直播场景第一个加载的一定是直播控件,所以它一定是第一个。但是其实保险起见,还是遍历一下最好。

aliplayer就更复杂了,里面大量的使用了document,比如:

document.activeElement && document.activeElement !== e && document.activeElement.blur()
复制代码

r = document.createEvent("MouseEvents")
复制代码

这些都是无法正常执行的,因为我们的player被shadow封装,所以document访问不了。这就需要将所有的document替换成document.getElementsByTagName("flt-platform-view")[0].shadowRoot,为了方便可以新建一个全局变量方便替换。 这里也没有使用遍历,同样因为在播放场景播放器一定是第一个加载。

总结

通过最后对两个sdk的修改可以感觉到,每次这样处理并不是很好的方法,如果要更新这些sdk就需要重新维护一次。所以最好的办法就是在flutter2.0上使用CanvasKit渲染,当然前提是先解决Image跨域的问题,这个还需要后续研究。如果你的项目不涉及Image加载网络图片跨域,那么直接使用CanvasKit吧,这些js sdk不需要任何处理可以直接使用。

Supongo que te gusta

Origin juejin.im/post/7076625832470380580
Recomendado
Clasificación