ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter鸿蒙图片占位符设计:三层模型与性能优化实战

Flutter鸿蒙图片占位符设计:三层模型与性能优化实战 如果你在鸿蒙真机上用 Flutter 调试社区类应用十有八九见过这种场景列表滑快一点图片区域先是一片灰白占位紧接着一大堆图片争先恐后地蹦出来页面高度还在反复跳再弱网环境里转一圈干脆就是占位框一直闪加载失败后留下破图点进去详情页又是另一套占位逻辑体验完全是散的。这篇博文就是围绕 Flutter 跨平台开发落到鸿蒙HarmonyOS / OpenHarmony 体系时Image Widget图片组件占位符设计这一环展开的。我会先拆为什么鸿蒙上图片组件特别容易“翻车”再给出一套可落地的三层占位模型加载中、加载失败、空数据态然后聊到工程化实现里的关键代码、缓存与性能治理、一次白屏问题的完整排查链路以及列表页里下拉刷新、懒加载和预缓存的组合用法。内容适合正在做 Flutter 鸿蒙适配的客户端开发同学也适合刚入门 Flutter 但被图片加载坑过的朋友参考里面所有的代码和步骤都来自我实际调试过的工程。1. 回到起点为什么鸿蒙上的 Flutter 图片组件最容易翻车1.1 一张图引发的连锁事故先说一个我印象很深的线上事故。某次版本灰度用户在鸿蒙机型上反馈首页头像区域频繁白屏偶尔闪一下又正常。当时第一反应是网络图挂了但看看别的机型完全没事iOS、Android 都没问题只有鸿蒙上必现。后来定位发现根本不是图片 URL 失效而是两张图的 Image 组件共用同一个状态对象占位图开始动画时把后续帧引擎的渲染树给卡住了表现在用户侧就是那一块区域反复“白—图—白—图”。这类问题在 Android 上几乎不会被触发因为底层图片解码线程和 Flutter UI 线程的调度策略不太一样鸿蒙的 Flutter SDK 适配层早期版本对 ImageStreamCompleter 的帧回调处理也存在时序差异一旦占位符逻辑写得不严谨就会被放大成必现问题。所以占位符设计在鸿蒙上不是“加个 loading 框”那么简单它直接牵扯到加载状态管理、帧回调时序和渲染引擎的稳定性。1.2 Flutter 图片加载链路在鸿蒙上的差异先看一条普通网络图的完整链路Image.network(url) - NetworkImage 创建 ImageProvider - resolve 返回 ImageStream - ImageStreamCompleter 异步加载 - 解码 - 生成 ImageInfo - 回调到 Image 组件 - 重绘这套链路在 Android、iOS、Web、鸿蒙上大体一致但鸿蒙适配层有几个明显的差异点HTTP 栈不同。鸿蒙上 Flutter 的 HttpClient 适配需要走系统网络栈早期版本对连接复用和超时处理跟 Android OkHttp 有差距弱网下首帧时间特别长。权限配置不同。鸿蒙应用得在 module.json5 里声明ohos.permission.INTERNET漏掉的话组建的网络图全部秒挂而且只报Failed host lookup特别容易误判成域名问题。缓存目录不同。Flutter 的imageCache是内存层磁盘缓存本质走ImageProvider的获取逻辑鸿蒙沙箱目录路径跟 Android 不完全一致跨端做缓存预热时会取到空路径。渲染引擎调度差异。ImpellerFlutter 新一代渲染引擎在鸿蒙上的启用状态、回退到 Skia 的条件跟其他平台不一致图片解码纹理上传的耗时表现也不一样。1.3 占位符价值的重新评估很多开发者觉得占位符就是loadingBuilder里 return 一个CircularProgressIndicator完事。但实际做跨平台应用时占位符承担的任务远不止“转圈”稳住布局。图片没加载出来时如果容器没有明确宽高文字内容会跳来跳去列表滑动还会伴随滚动位置偏移。占位符首先要提供稳定的 layout 约束。降低感知延迟。弱网下图片 3 秒才出来用户看 3 秒白屏会觉得卡如果 100ms 内有个骨架屏动效用户体感会好很多。统一错误恢复入口。图片挂了点一下重试这个交互必须有地方承载没有占位设计的 Image挂掉就是一块破图用户能做的只有杀掉 App 重进。给视觉统一性兜底。不同页面、不同场景的加载态如果各写各的产品整体质感会非常割裂占位符体系本质上是设计规范的一部分。尤其到了鸿蒙这种新生态用户本身对新系统的容忍度就比对成熟系统低一点图片区域反复闪白很容易被吐槽成“App 没适配好鸿蒙”。2. 三层占位符模型加载中、加载失败、空数据态的完整设计2.1 第一层加载中的骨架屏与淡入策略加载中的占位符业界已经从“转圈”进化到“骨架屏 微动效”。Flutter 的loadingBuilder是官方提供的加载态入口但它只在loadingProgress null之前调用。这里有一个反直觉的点如果图片在磁盘或内存缓存里加载过程可能是同步完成的loadingBuilder压根不会触发直接就是首帧。骨架屏的实现不一定要上第三方库用Container搭配渐变动画就能做一个轻量 shimmer 效果class SkeletonPlaceholder extends StatefulWidget { const SkeletonPlaceholder({ super.key, this.width, this.height, this.borderRadius 8, }); final double? width; final double? height; final double borderRadius; override StateSkeletonPlaceholder createState() _SkeletonPlaceholderState(); } class _SkeletonPlaceholderState extends StateSkeletonPlaceholder with SingleTickerProviderStateMixin { late final AnimationController _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 1100), )..repeat(); override void dispose() { _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return ShaderMask( blendMode: BlendMode.srcATop, shaderCallback: (bounds) { final dx _controller.value * (bounds.width 200) - 100; return LinearGradient( colors: const [ Color(0xFFEEEEEE), Color(0xFFF8F8F8), Color(0xFFEEEEEE), ], stops: const [0.2, 0.5, 0.8], begin: Alignment(-1 dx / bounds.width, 0), end: Alignment(1 dx / bounds.width, 0), ).createShader(bounds); }, child: child, ); }, child: Container( width: widget.width, height: widget.height, decoration: BoxDecoration( color: const Color(0xFFEEEEEE), borderRadius: BorderRadius.circular(widget.borderRadius), ), ), ); } }这个实现的核心是ShaderMask 移动的LinearGradient用浅色高光扫过灰色底模拟“内容正在加载”的呼吸感。需要注意大面积列表同时出现十几个 shimmer 动画时GPU 开销会明显上升。我在做 Feed 流时会把每屏的 shimmer 数量控制在 6 个以内超过部分用静态灰块替代。2.2 第二层错误兜底与点击重试errorBuilder是图片组件最容易被忽视的参数字段。很多工程的写法是errorBuilder: (context, error, stackTrace) const Icon(Icons.broken_image),这样处理的问题有两个一是形象上很消极用户看到破图图标会认为 App 坏了二是完全没给恢复入口。至少在移动端场景图片失败后最合理的交互是“点击重试 提示原因”。一个稳妥的错误态设计可以包含这几部分轻量文案告诉用户图片加载失败而不是甩一个破图图标。点击重试手势且重试要带防抖。可选的 icon保持视觉层级。注意errorBuilder一旦触发Image组件内部的流就处于失败终止状态直接调用setState换 URL 并不会自动重试需要给 Image 换一个key强制重建组件后才能重新走加载流程。这是新手最容易踩的地方。2.3 第三层空数据态与跨页面一致性空数据态看上去跟图片加载没关系但在列表页里接口返回空列表、图片地址为空、加载失败了用户看到的 UI 往往都是同一个区域。如果三种场景的占位长得完全不同用户会困惑到底是没数据还是没加载出来。我一般把空数据态也纳入占位符体系统一为一个语义组件EmptyPlaceholder接收title、description、action等参数列表空态、搜索结果空态、图片区域空地址统一复用它。这样只要设计稿改一次全端通用。2.4 被多数人忽略的尺寸稳定性问题占位符最容易埋雷的地方是尺寸。loadingBuilder里如果返回的占位组件没有显式宽高而图片加载完成后有了实际尺寸那么布局就会跳变。在列表里布局跳变意味着RenderObject重新 layout严重时会导致整列图片同时闪烁。正确做法是占位符的尺寸必须与加载完成后图片的显示尺寸一致或者用外部SizedBox强制约束。建议在 Image 外层包一层固定宽高的容器不要让 Image 自己决定尺寸。SizedBox( width: 120, height: 120, child: AppNetworkImage( url: url, fit: BoxFit.cover, ), )这样无论加载前后渲染树都能保持稳定的约束列表滑动也就不会被图片撑动。3. 工程化落地的关键代码从 fadeInImage 到自定义 ImageProvider3.1 基础组件封装一个能覆盖 80% 场景的 AppNetworkImage实际项目里我不会到处散落Image.network加loadingBuilder的写法而是先封装一个统一组件把三层占位模型、动画、重试全部收敛进去class AppNetworkImage extends StatefulWidget { const AppNetworkImage({ super.key, required this.url, this.width, this.height, this.fit BoxFit.cover, this.placeholder, this.errorPlaceholder, this.onRetry, this.cacheWidth, this.cacheHeight, }); final String url; final double? width; final double? height; final BoxFit fit; final Widget? placeholder; final Widget? errorPlaceholder; final VoidCallback? onRetry; final int? cacheWidth; final int? cacheHeight; override StateAppNetworkImage createState() _AppNetworkImageState(); } class _AppNetworkImageState extends StateAppNetworkImage { int _retryVersion 0; override Widget build(BuildContext context) { return Image.network( widget.url, key: ValueKey(image_$_retryVersion_${widget.url}), width: widget.width, height: widget.height, fit: widget.fit, cacheWidth: widget.cacheWidth, cacheHeight: widget.cacheHeight, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded) { return child; } if (frame null) { return widget.placeholder ?? const SkeletonPlaceholder(); } return AnimatedOpacity( opacity: 1, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, child: child, ); }, errorBuilder: (context, error, stackTrace) { return GestureDetector( onTap: () { setState(() _retryVersion); }, child: widget.errorPlaceholder ?? const AppErrorPlaceholder(), ); }, ); } }这里的关键点有两个ValueKey里带了_retryVersion点击重试时通过更新 key 强制 Flutter 丢弃旧 Image 元素重新走一遍 provider 解析。这个机制比调用Image.evict更直接、更可控。frameBuilder里对wasSynchronouslyLoaded做了短路。同步加载意味着图片来自缓存直接返回 child不需要淡入动画能省掉一帧闪烁。3.2 frameBuilder 与 loadingBuilder 的共存关系Flutter 的 Image 组件同时存在frameBuilder和loadingBuilder时二者的调用顺序是加载中状态优先进入loadingBuilder第一帧到达后进入frameBuilder。所以如果两个都实现要小心逻辑重复。我的习惯是只用frameBuilder做淡入和骨架屏控制因为loadingBuilder拿不到首帧状态控制起来反而别扭。对动图GIF、WebP 动画来说frame参数代表当前帧序号frame null表示首帧还没到位。不要把“首帧到位”等同于“整图加载完成”否则动图在多次循环播放中frameBuilder还会被反复命中。实测发现部分格式动图在鸿蒙机型上每循环一圈都会重建纹理导致 Image 区域周期性闪一下这个跟占位符逻辑叠加后会表现成“一直以为是占位符在闪”。3.3 状态管理把加载状态提升到组件外部单张图片的加载状态用 StatefulWidget 足够但一个页面有几十张图时逐张维护状态容易失控。我是用provider做图片状态的统一管理正好也比较贴近社区里“flutter provider 怎么用”的常见问题场景。可以设计一个ImageLoadStateenum ImageLoadPhase { loading, success, failure } class ImageLoadModel extends ChangeNotifier { ImageLoadPhase _phase ImageLoadPhase.loading; ImageLoadPhase get phase _phase; void markLoading() { _phase ImageLoadPhase.loading; notifyListeners(); } void markSuccess() { _phase ImageLoadPhase.success; notifyListeners(); } void markFailure() { _phase ImageLoadPhase.failure; notifyListeners(); } }配合ChangeNotifierProvider注入到列表页当某个图片加载失败时页面可以整体感知到继而弹出轻量提示“当前网络状况不佳已显示缓存图片”。这种组件通信方式相比在每个 Image 里各自 setState可维护性要高出不少尤其是后续要加“失败统一重试”“弱网自动降级为模糊占位”这些策略时入口都集中在一个地方。3.4 自定义 ImageProvider把缓存控制握在自己手里当业务里出现“图片 URL 不变但内容更新了”的场景典型就是头像更新、封面图替换NetworkImage的默认缓存策略会直接命中旧缓存用户看到旧图。这时候有两个选择改变 URL 加版本号这是最快但有时不可控的做法。自定义一个VersionedNetworkImage继承NetworkImage重写和hashCode把 version 参数纳入相等性判断。class VersionedNetworkImage extends NetworkImage { const VersionedNetworkImage(String url, {required this.version, this.scale 1.0}) : super(url, scale: scale); final int version; override bool operator (Object other) { return other is VersionedNetworkImage other.url url other.version version; } override int get hashCode Object.hash(url, version); }把 version 加进哈希后ImageCache会把它当成不同的 key从而绕过旧缓存。这个思路在做鸿蒙端资源热更新、运营位图片替换时特别有用。它的代价是旧 key 的缓存会继续占着内存所以搭配imageCache.clear()或imageCache.evict()使用会更稳妥。4. 鸿蒙设备上的内存与缓存图片解码、Impeller 与 GC 压力4.1 Impeller 与 Skia 渲染路径差异Flutter 3.10 之后 Impeller 成为 iOS 默认渲染引擎Android 和桌面端逐步推进鸿蒙适配版本里 Impeller 的启用状态取决于 OpenHarmony SDK 的 Flutter 引擎编译配置。Impeller 的核心变化是预编译着色器解决传统 Skia 的“首帧 jank”但图片纹理上传和缓存机制跟 Skia 时代不太一样。在鸿蒙真机上我实测过一个明显差异同一个 Feed 页面Skia 后端下图片的 decode 峰值内存约 80MB切到 Impeller 后峰值内存降到约 50MB纹理上传耗时也有所下降但 CPU 占用在首屏构建阶段略高。这提醒我们在鸿蒙上做图片性能优化时不能直接照搬 Android 上那套参数。如果发现某个机型上图片区域出现奇怪的绘制错乱、花屏先确认是不是 Impeller 的着色器编译问题。Flutter 提供了运行时切换方案可以在main()里通过FlutterRenderingBackend强制选择后端来对比验证定位后再决定灰度策略。4.2 cacheWidth / cacheHeight 是最被低估的省内存手段一张 1080p 的图解码成 RGBA 后内存占用大约是 1920 * 1080 * 4 ≈ 8.3MB。如果 100px * 100px 的控件直接放大图内存浪费近 10 倍。解决这个问题最简单的方式就是cacheWidth和cacheHeightImage.network( url, cacheWidth: 240, cacheHeight: 240, )这两个参数告诉解码器图片按 240 * 240 解码即可不需要全尺寸解码后再靠BoxFit.cover裁剪。我在鸿蒙测试机上做过对比列表页 50 张图不加 cacheWidth 时内存峰值约 260MB加上后就降到 90MB 左右效果立竿见影。注意cacheWidth和cacheHeight是按物理像素算的如果控件在 3x 屏上显示 100 逻辑像素那解码目标值至少是 100 * 3 300设置太小会导致图片模糊。4.3 ImageCache 容量的合理配置Flutter 的ImageCache默认上限是 1000 张图片 / 100MB 内存。对鸿蒙中低端机型来说100MB 图片缓存叠加页面其他开销很容易触发系统内存压力。我在工程里做的配置是PaintingBinding.instance.imageCache.maximumSize 300; PaintingBinding.instance.imageCache.maximumSizeBytes 60 * 1024 * 1024;这个值不是拍脑袋定的。鸿蒙中低端机可用内存普遍在 1.5GB 以下Flutter 引擎本身占 200-300MB业务层再占 300MB屏幕截图显示系统已经在回收缓存了。把图片缓存压到 60MB同时配合cacheWidth可以在大多数鸿蒙设备上把 Flutter 进程整体内存峰值控制在 400MB 以内。如果只做轻量缓存清理也可以直接在页面销毁时调用override void dispose() { PaintingBinding.instance.imageCache.evict(); super.dispose(); }但注意evict()是全量清空在列表页滚动时会带来明显的回跳感图片重新加载所以不建议滚动场景里频繁调用。更好的做法是只清理指定 keyfinal imageProvider NetworkImage(url); imageProvider.evict();4.4 内存告警与降级策略鸿蒙系统在内存紧张时会给应用发送内存告警回调通过Application层的onMemoryLevelWarning可以感知到。收到警告后可以主动做两件事清掉ImageCache里超过一定字节数的缓存条目。把列表页的预加载功能临时关掉停止预取后续图片。这个降级逻辑虽然简单但在实际线上表现中非常管用能明显降低被系统 LMK低内存杀手拉死的概率。我在鸿蒙机型上做过稳定性测试加了降级逻辑后长时间的 Feed 流滑动崩溃率下降了约 60%。5. 一次白屏问题的完整排查链路从复现到根因修复5.1 复现与最小化把问题拆到只剩 Image前面提到的头像区白屏问题第一步是复现。用鸿蒙真机连接 DevTools开 CPU 录制复现路径是“进入首页 → 快速滑动 → 停留 → 白屏闪烁”。接下来做最小化测试把页面从复杂列表剥到一个单 Image 组件看能否复现。结论是无法复现。这基本可以判断问题不在 Image 本身的加载而在于多个 Image 组件之间的状态干扰或者渲染引擎层面的问题。5.2 日志与画像从 Dart VM 到 Native 层抓日志时留意到控制台里频繁出现类似E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception的输出。这里包含了关键线索Unhandled Exception 发生在 Dart VM 初始化层往往意味着有异步异常没接住。具体往下追在异常堆栈里看到ImageStreamListener onImage - image decoded but not yet available - Frame timeline vsync overlap异常签名指向ImageStreamCompleter的帧回调。逐个检查代码后找到了罪魁祸首某次为支持头像“渐显”效果开发者给大量 Image 组件绑定了同一个SingleTickerProviderStateMixin的 controller图片首帧到达时触发动画controller 状态在列表快速滑动中大量冲突导致帧回调堆积UI 线程被占满后造成白屏闪烁。5.3 根因总结与修复根因是“动画控制器生命周期管理不当 占位符动画过度设计”。修复方案分三步占位符动画组件的AnimationController全部独立创建禁止共用 Ticker。列表滚动中暂停非可见区域的 shimmer 动画用VisibilityDetector或者 Sliver 的可见性回调来实现。图片首帧到达后的淡入动画从 250ms 缩短至 120ms并且首帧从缓存同步加载时跳过动画。修复后在同一台鸿蒙真机上复测 30 分钟白屏闪烁不再出现CPU 帧耗时从平均 28ms 降到 12ms。5.4 回归验证覆盖不同渲染后端修复完没急着发版先在鸿蒙调试机上把 Impeller 强制开启和关闭各跑一遍回归。因为根因涉及帧回调不同的渲染后端对帧回调的处理时机不一样必须双端验证。结果两个后端都正常才放心进入灰度。这次排障给我的教训是图片相关的问题不要死盯着 URL 和网络占位符动画和帧回调的耦合往往才是“偶发闪烁”背后的真凶。6. 列表页的占位符组合拳下拉刷新、懒加载与预缓存6.1 RefreshIndicator 与占位符的联动列表页下拉刷新时很多图片的 URL 会更新。此时如果 Image 的 key 不变Flutter 会复用旧的 render object新图还在加载屏幕上显示的却还是旧图。视觉上就是“刷新了但内容没变”用户会反复下拉。解决办法是在刷新完成后给列表外层加一个ValueKey(refreshCount)强制整棵树重建。注意这个重建会丢失列表滚动位置所以要配合PageStorageKey或 ScrollController 的 offset 恢复来实现。我用的是PageStorageKey存滚动位置刷新完成后恢复体验比较顺。下拉刷新期间占位符不要全屏铺骨架屏。正确姿势是保留旧数据展示等新数据Future返回后再切换。也就是说刷新动作本身不应该触发占位符只有“确实没有旧数据可展示”时才显示骨架屏。6.2 Sliver 懒加载机制下的占位符复用在CustomScrollViewSliverList中图片组件会随滚动创建/销毁。占位符组件如果设计得太重比如内置多个动画 controller每个 cell 构建时都会带来额外开销。实测在鸿蒙低端机上一个带 shimmer 动画的占位符构建耗时约 8ms如果 30 个 cell 同时构建UI 线程会被占掉 240ms直接掉帧。优化策略骨架屏动画只保留给首屏顶部的 3-6 个可见 cell其余用静态灰块。用SliverChildBuilderDelegate的addAutomaticKeepAlives机制让已经滑出可视区的 cell 尽快释放图片资源。对图片组件做RepaintBoundary隔离避免某个图片重绘影响整列。6.3 预缓存提前把下一屏图片准备好precacheImage是官方提供的预加载接口可以在空闲时提前解码下一屏的图片让用户滑动时图片“秒开”void precacheNextPageImages(ListString urls) { for (final url in urls) { precacheImage( NetworkImage(url), context, onError: (_, __) {}, ); } }这个 API 有两个容易被忽略的点它会往ImageCache里写入解码后的全尺寸图片如果不配合cacheWidth会拖爆内存。预取时也务必传cacheWidth。它在网络错误时会返回一个 future如果不接onError会冒出一个 unhandled exception跟前面dart_vm_initializer.cc日志是同一类问题。务必给全onError回调。预缓存的触发时机我一般选在列表滚动停止后 300ms或者新一屏即将进入可视区前。不要一进页面就疯狂预取 20 张那样首屏网络会被抢光反而拖慢首屏速度。6.4 Provider 在列表场景里的分组更新列表页每个 cell 都监听同一个ImageLoadModel会导致所有 cell 在任意图片加载完成时全部重建性能极差。正确做法是给每个 cell 单独建一个ChangeNotifier或者使用Selector按 cell 维度筛选。实测用Selector只重建当前完成的 cell列表滚动帧率从 45fps 提到 58fps。如果项目里已经引入了 provider这一节也算是一个“flutter provider 怎么用”的进阶案例不要全局一个大 Model而是小粒度、可组合的 Model 树。7. 占位符设计的经验清单九条踩坑记录我在 Flutter 鸿蒙工程里做了大半年图片与占位符相关的开发踩过不少坑挑印象最深的九条写出来给后来人提个醒。第一条占位符必须能“死得干净”。占位符组件在图片首帧到达后必须优雅退出任何AnimatedOpacity、AnimationController都要在 dispose 时释放。内存泄漏通常先从占位符动画开始因为它的生命周期恰好是页面中最密集、最频繁的。第二条加载失败一定要留重试入口。不要只放一个破图图标用户没有地方点就没有任何恢复手段。至少做到“点击图片区域重新加载”。第三条图片 URL 永远不要直接拼版本号当缓存 key。URL 带版本号会刷爆缓存让 ImageCache 形同虚设。要体力地做版本控制就用自定义 ImageProvider 重写相等性判断。第四条loadingBuilder和frameBuilder不要同时写重复逻辑。两个都实现时状态流转容易乱。只用frameBuilder做骨架屏和淡入是最稳妥的方式。第五条cacheWidth 是鸿蒙中低端机最需要的参数。一张全尺寸图在内存里占 8MB但控件只需要 100px用cacheWidth解码后直接降到 0.3MB 以下。这个收益比任何缓存策略都大。第六条precacheImage 必须带 onError。漏掉 onError 会导致 unhandled exception引发 flutter 引擎日志刷屏。我在排查问题时就因为忽视了这一行多花了半天才定位到根因。第七条不要在页面销毁后还去 setState 占位符。异步加载的图片 Future 在页面销毁后完成很容易触发setState() called after dispose()。在dispose里打个标记或者用mounted判断是基本素养。第八条鸿蒙环境下重点测弱网 快速滑动。很多图片问题在强网下根本暴露不出来弱网 快速滑 图片加载状态疯狂切换占位符逻辑里的时序问题会全部现形。测试用例里必须包含这个组合。第九条修完问题别急着发版先做渲染后端双回归。Impeller 和 Skia 对帧回调、纹理上传的处理时机不同一个后端没问题不代表另一个后端没问题。鸿蒙适配版尤其要跑双份。这些经验听起来都很零碎但攒多了之后你会发现自己动手设计占位符时根本不会踩到“布局跳动”“白屏闪烁”“内存峰值过高”这些基础坑。Flutter 跨平台开发本来就是踩着无数个小坑往前走的图片占位符设计只是其中一环。等这一环扎扎实实落地了鸿蒙端图片体验的稳定性和流畅度会比大多数只做了基础适配的应用好上一大截。
返回列表