【Flutter进阶】 Web开发中如何加载网络图片

前言

Google推出了Flutter来进行跨平台开发,并且对于支持的平台也在一点点的丰富起来。最近正好有一个项目,就来试试用Flutter进行Web开发,没想到坑还是很多。

上一篇文章我们探讨了《Flutter Web中刷新与后退问题》

本篇文章重点来说说图片的相关问题。

如何加载网络图片

在flutter web上也可以使用Image这个widget来加载显示图片。但是涉及到网络图片的时候就可能会出现问题,现象是不显示图片,控制台报错:

Failed to load network image.

Image URL: https://cdnimagelive.knowbox.cn/image/784111920965119.png

Trying to load an image from another domain? Find answers at:

https://flutter.dev/docs/development/platform-integration/web-images

看提示应该与跨域有关,根据官网的相关文档,Image这个widget在web上支持有限,这时候建议使用其他方式来加载图片

The web offers several methods for displaying images. Below are some of the common ones:

The built-in and HTML elements.

The drawImage method on the element.

Custom image codec that renders to a WebGL canvas.

我们选择使用img标签来显示,通过ImageElements来实现即可,代码如下:

import 'dart:html';
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';

class WebImage extends StatelessWidget{
    
    
  String url;
  double width;
  double height;

  WebImage(this.url, this.width, this.height);

  
  Widget build(BuildContext context) {
    
    
    String _divId = "web_image_" + DateTime.now().toIso8601String();
    // ignore: undefined_prefixed_name
    ui.platformViewRegistry.registerViewFactory(
      _divId,
          (int viewId) => ImageElement(src: url),
    );
    return SizedBox(
      width: width,
      height: height,
      child: HtmlElementView(key: UniqueKey(),
        viewType: _divId,),
    );
  }

}

注意,这种方式图片不能按照自身尺寸显示,所以必须设置宽高才可以。

如果想使用圆形图片,则用ClipOval包装即可,如下:

ClipOval(
  child: WebImage("https://cdnimagelive.knowbox.cn/image/784111920965119.png", 50, 50)
)

列表中的网络图片

但是上面方式有一个很严重的问题,如果一个页面中图片特别多,比如列表,那么使用这种方式的话在pc上运行会特别卡,甚至卡死。会出现大量如下信息:

Flutter: restoring WebGL context.

Flutter: restoring WebGL context.

Flutter: restoring WebGL context.

...

════════ Exception caught by scheduler library ═════════════════════════════════════════════════════

The following JSNoSuchMethodError was thrown during a scheduler callback:

TypeError: Cannot set property 'name' of null

When the exception was thrown, this was the stack: 

https://unpkg.com/[email protected]/bin/canvaskit.js 223:448  ga

https://unpkg.com/[email protected]/bin/canvaskit.js 1:1      Surface$_flush

https://unpkg.com/[email protected]/bin/canvaskit.js 12:230   flush

lib/_engine/engine/canvaskit/surface.dart 290:14                  flush

lib/_engine/engine/canvaskit/surface.dart 266:5                   [_presentSurface]

... ════════════════════════════════════════════════════════════════════════════════════════════════════

其实在上面的官方文档中已经提到了

As of today, using too many HTML elements with the CanvasKit renderer may hurt performance. If images interleave non-image content Flutter needs to create extra WebGL contexts between the elements. If your application needs to display a lot of images on the same screen all at once, consider using the HTML renderer instead of CanvasKit.
如果在一个页面有很多图片,则使用HTML renderer来代替CanvasKit。

那么什么是HTML renderer,什么是CanvasKit,如何使用这两个?

HTML renderer和CanvasKit

根据web-renderers 官方文档,flutter对于web的渲染是有两种模式,即html和Canvaskit。

Canvaskit将 Skia 编译成 WebAssembly 格式,并使用 WebGL 渲染。应用在移动和桌面端保持一致,有更好的性能,以及降低不同浏览器渲染效果不一致的风险。但是应用的大小会增加大约 2MB。

默认情况下flutter自动选择渲染器。移动端浏览器选择 HTML,桌面端浏览器选择 CanvasKit。

但是我们如果想使用HTML renderer,就必须强制设置一下,而这个设置并不是在代码中,而是在启动参数中,如下

flutter run -d chrome --web-renderer html (或canvaskit)//运行命令

flutter build web --web-renderer html (或canvaskit) //编译打包

我们通过在终端执行flutter run -d chrome --web-renderer html 来运行我们的应用,就会发现即使页面中有很多图片,也不会出现明显卡顿卡死的现象了。

如果使用Android studio,则需要对运行进行配置,如图:

5EC47B15-1734-47A3-97F5-EF71F551A55E.png

在配置中的Additional arguments一栏中添加--web-renderer html即可,再运行就会以HTML renderer的方式来运行。

最后编译打包的时候也要加上--web-renderer html才可以。

文本无法选择

慢慢的,使用html render的问题就显示出来了,其中一个问题就是改成html render后发现所有文字无法选择了,导致无法进行复制等行为。
运行后通过开发者工具查看页面节点信息,可以看到

整个body都被设置成了user-select: none; touch-action: none,这样就导致整个页面上的文本都无法选择。

这个是flutter框架的行为,目前在flutter项目中还没有发现可以取消这个配置的api。

Shadow Root问题

更严重的问题是ShadowDom的问题。

为什么找不到节点?

在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);
  
  State<StatefulWidget> createState() {
    
    
    return _lottieWidget;
  }

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

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

class _LottieWidget extends State<LottieWidget> {
    
    

  
  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();
  }

  
  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,可以参考https://blog.csdn.net/qq_42872073/article/details/125607826

正是因为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 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,所以需要遍历来找到对应的shadowRoot。

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

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

Flutter2.0上的Shadow DOM

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

之前我们在解决image跨域的问题时提到过,flutter有两种渲染模式:CanvasKit和Html

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

1619696122700.jpg

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

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

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

总结

通过上面一系列折腾大家应该也能看出来,目前Flutter对Web开发的支持还是不够的,很多功能还没有完善。如果各位同学想使用Flutter进行Web开发就需要慎重考虑,我建议至少等Google对这部分进行完善,迭代几个正式版本后再尝试。

猜你喜欢

转载自blog.csdn.net/chzphoenix/article/details/122671315