哈喽大家好,我是你们的老朋友,爱学习的小齐哥哥。上个月,我投入开发了一款智能家居控制类的HarmonyOS应用,其中有一个功能需要在商品列表中展示动态的进度条动画——每个商品卡片都有一个圆环进度条,显示库存剩余百分比。我使用了Canvas绘制这些动画圆环,代码运行起来效果很酷炫,直到我开始滑动列表。

实际现象:当列表中有8-10个带Canvas动画的商品卡片时,上下滑动页面会出现明显的抖动和卡顿。动画本身是流畅的,但一旦开始滚动,整个页面的响应就变得迟滞,用户体验大打折扣。更让人困惑的是,在静态测试时性能表现良好,只有在动态滚动时才会出现问题。

“这不就是个简单的进度条动画吗?”我最初不以为意。我尝试了各种优化:减少动画帧率、降低Canvas分辨率、甚至使用了离屏渲染技术。但问题依旧存在,滑动时的卡顿感让整个应用显得很不专业。

今天,我将彻底复盘并分享这次“Canvas动画抖动”危机的完整解决之旅。这不仅仅是一个动画优化问题,更是一次对HarmonyOS渲染机制的深度探索。你将掌握从问题定位、根因分析到性能优化的全套方案,从此告别列表滑动卡顿的困扰。

一、问题现象:那个“卡顿”的动画列表

我的应用场景很常见:一个商品列表,每个商品卡片右侧有一个圆环进度条,实时显示库存剩余比例。圆环从0%到实际值有一个平滑的填充动画。

我写下了看似标准的Canvas动画代码:

// 问题代码:使用离屏Canvas绘制动画
import { display } from '@kit.ArkUI';

@Component
struct ProductCard {
  @Local progress: number = 0;
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private offCanvas: OffscreenCanvas = new OffscreenCanvas(100, 100); // 离屏Canvas

  // 绘制圆环进度
  drawProgressRing = (): void => {
    let offContext = this.offCanvas.getContext('2d', this.settings);
    
    // 清空画布
    offContext.clearRect(0, 0, 100, 100);
    
    // 绘制背景圆环
    offContext.beginPath();
    offContext.arc(50, 50, 40, 0, Math.PI * 2);
    offContext.strokeStyle = '#EAF2FF';
    offContext.lineWidth = 8;
    offContext.stroke();
    
    // 绘制进度圆环
    offContext.beginPath();
    offContext.arc(50, 50, 40, -Math.PI/2, -Math.PI/2 + (Math.PI * 2 * this.progress));
    offContext.strokeStyle = '#337DFF';
    offContext.lineWidth = 8;
    offContext.stroke();
    
    // 将离屏Canvas内容绘制到主Canvas
    let image = this.offCanvas.transferToImageBitmap();
    this.context.transferFromImageBitmap(image);
    
    // 动画循环
    setTimeout(() => {
      if (this.progress < targetValue) {
        this.progress += 0.01;
        this.drawProgressRing();
      }
    }, 16); // 约60fps
  }

  build() {
    Row() {
      // 商品信息...
      Column() {
        Canvas(this.context)
          .width(100)
          .height(100)
          .onReady(this.drawProgressRing)
      }
    }
  }
}

核心问题:当这样的卡片在List中重复10次以上,滑动列表时会出现明显的抖动。动画帧率从60fps骤降到30fps以下,用户能明显感觉到卡顿。

二、背景知识:Canvas绘制的“明暗”双轨制

要定位问题,必须理解HarmonyOS Canvas的两种绘制模式及其性能特性。

绘制模式

技术原理

性能特点

适用场景

离屏绘制

使用OffscreenCanvas在内存中绘制,然后通过transferToImageBitmap()传输到主线程

CPU计算,传输开销大,性能较差

复杂静态图形、图像处理、滤镜效果

在屏绘制

直接在Canvas上下文中绘制,GPU加速渲染

GPU硬件加速,性能优秀

动态动画、频繁更新的UI、列表中的Canvas

关键洞察

  • 离屏绘制的双重开销:CPU绘制 + 内存传输到GPU,在滚动时会造成主线程阻塞。

  • List的渲染机制:HarmonyOS的List组件在滚动时会频繁触发子组件的重绘,如果每个子组件都在进行CPU密集型的离屏绘制,就会导致帧率下降。

  • 60fps的黄金标准:为了保持流畅的视觉体验,每帧的渲染时间需要控制在16.7ms以内。离屏绘制很容易突破这个限制。

三、问题定位:性能Trace图揭示的真相

通过HarmonyOS DevEco Studio的性能分析工具,我抓取了滑动时的性能Trace图,发现了问题的关键。

Trace图分析要点

  1. 主线程阻塞:在橙色标记的时间段内,Canvas.FireReadyEvent方法执行耗时超出预期。

  2. 离屏绘制耗时OffscreenCanvasRenderingContext2D的相关方法出现在关键路径上。

  3. 帧丢失:多个VSync信号期间未能完成渲染,导致掉帧。

性能数据对比

场景

平均帧率

每帧耗时

滑动流畅度

离屏绘制(10个卡片)

28fps

35ms

明显卡顿

在屏绘制(10个卡片)

58fps

17ms

基本流畅

离屏绘制(20个卡片)

15fps

66ms

严重卡顿

在屏绘制(20个卡片)

52fps

19ms

轻微卡顿

问题根源:离屏绘制使用CPU进行图形计算,然后将结果传输到GPU,这个过程在List滚动时会频繁触发,导致主线程阻塞,进而引起页面抖动。

四、解决方案:从离屏绘制到在屏绘制的性能飞跃

基于以上分析,我将绘制方式从离屏绘制改为在屏绘制,性能立即得到显著提升。

改造后的核心代码

import { display } from '@kit.ArkUI';

@Component
struct OptimizedProductCard {
  @Local progress: number = 0;
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  // 优化后的绘制方法:直接在屏绘制
  drawProgressRing = (): void => {
    // 关键优化:直接使用主Canvas上下文,避免离屏绘制
    const ctx = this.context;
    
    // 清空画布(在屏绘制无需transfer)
    ctx.clearRect(0, 0, 100, 100);
    
    // 绘制背景圆环
    ctx.beginPath();
    ctx.arc(50, 50, 40, 0, Math.PI * 2);
    ctx.strokeStyle = '#EAF2FF';
    ctx.lineWidth = 8;
    ctx.stroke();
    
    // 绘制进度圆环
    ctx.beginPath();
    ctx.arc(50, 50, 40, -Math.PI/2, -Math.PI/2 + (Math.PI * 2 * this.progress));
    ctx.strokeStyle = '#337DFF';
    ctx.lineWidth = 8;
    ctx.stroke();
    
    // 优化动画调度
    requestAnimationFrame(() => {
      if (this.progress < targetValue) {
        this.progress += 0.01;
        this.drawProgressRing();
      }
    });
  }

  build() {
    Row() {
      // 商品信息...
      Column() {
        Canvas(this.context)
          .width(100)
          .height(100)
          .onReady(this.drawProgressRing)
      }
    }
  }
}

性能对比:改造前后的差异

优化点

离屏绘制方案

在屏绘制方案

性能提升

绘制方式

CPU绘制 + 内存传输

GPU直接绘制

减少60%绘制时间

内存使用

每个Canvas额外100x100x4≈40KB

无额外内存

节省400KB(10个卡片)

传输开销

每帧都需要bitmap传输

无传输开销

消除传输延迟

滚动性能

严重卡顿

基本流畅

帧率提升100%

五、进阶优化:五大性能优化技巧

除了改用在屏绘制,我还总结了一套完整的Canvas性能优化方案,让你的列表动画如丝般顺滑。

技巧一:requestAnimationFrame替代setTimeout

// ❌ 不推荐:setTimeout无法保证帧同步
setTimeout(() => {
  this.updateAnimation();
}, 16);

// ✅ 推荐:requestAnimationFrame与屏幕刷新同步
const animate = () => {
  this.updateAnimation();
  if (this.isAnimating) {
    requestAnimationFrame(animate);
  }
};
requestAnimationFrame(animate);

技巧二:Canvas分层绘制

对于复杂的Canvas场景,将静态内容和动态内容分离到不同的Canvas层:

@Component
struct LayeredCanvas {
  // 静态层:背景、边框等不变化的内容
  private staticContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(
    new RenderingContextSettings(true)
  );
  
  // 动态层:进度条、动画等频繁变化的内容
  private dynamicContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(
    new RenderingContextSettings(true)
  );

  aboutToAppear() {
    // 只绘制一次静态内容
    this.drawStaticContent();
  }

  drawStaticContent() {
    const ctx = this.staticContext;
    // 绘制背景、边框等静态元素
    ctx.fillStyle = '#F5F5F5';
    ctx.fillRect(0, 0, 100, 100);
    // ...其他静态绘制
  }

  drawDynamicContent() {
    const ctx = this.dynamicContext;
    // 只清除动态层,保留静态层
    ctx.clearRect(0, 0, 100, 100);
    // 绘制动态内容(进度条等)
    // ...
  }

  build() {
    Stack() {
      // 底层:静态Canvas
      Canvas(this.staticContext)
        .width(100)
        .height(100);
      
      // 上层:动态Canvas
      Canvas(this.dynamicContext)
        .width(100)
        .height(100)
        .onReady(() => {
          this.startAnimation();
        });
    }
  }
}

技巧三:智能重绘区域

只重绘发生变化的部分,而不是整个Canvas:

class SmartCanvasRenderer {
  private lastProgress: number = 0;
  
  drawProgressRing(ctx: CanvasRenderingContext2D, progress: number) {
    // 只重绘进度条变化的部分
    if (Math.abs(progress - this.lastProgress) > 0.01) {
      // 清除旧的进度条区域(计算精确区域)
      const startAngle = -Math.PI/2 + (Math.PI * 2 * this.lastProgress);
      const endAngle = -Math.PI/2 + (Math.PI * 2 * progress);
      this.clearArc(ctx, 50, 50, 40, startAngle, endAngle);
      
      // 绘制新的进度条
      ctx.beginPath();
      ctx.arc(50, 50, 40, -Math.PI/2, -Math.PI/2 + (Math.PI * 2 * progress));
      ctx.stroke();
      
      this.lastProgress = progress;
    }
  }
  
  clearArc(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, startAngle: number, endAngle: number) {
    // 精确清除扇形区域
    ctx.save();
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.arc(x, y, radius + 1, startAngle, endAngle); // +1确保完全清除
    ctx.closePath();
    ctx.clip();
    ctx.clearRect(x - radius - 1, y - radius - 1, radius * 2 + 2, radius * 2 + 2);
    ctx.restore();
  }
}

技巧四:List性能优化配置

List() {
  ForEach(this.productList, (item: Product) => {
    ListItem() {
      ProductCard({ product: item })
    }
  }, (item: Product) => item.id.toString())
}
.width('100%')
.height('100%')
.cachedCount(5) // 缓存5个ListItem,减少滚动时创建销毁开销
.edgeEffect(EdgeEffect.None) // 禁用边缘效果,提升滚动性能
.scrollBar(BarState.Off) // 关闭滚动条,减少绘制内容

技巧五:Canvas尺寸优化

@Component
struct OptimizedCanvas {
  // 根据设备像素比优化Canvas尺寸
  private getOptimizedSize(baseSize: number): number {
    const dpr = this.getUIContext().getDisplayDensity();
    // 对于非Retina屏幕,使用原始尺寸
    // 对于Retina屏幕,适当增加尺寸但不超过2倍
    return Math.min(baseSize * Math.min(dpr, 2), baseSize * 2);
  }

  build() {
    const canvasSize = this.getOptimizedSize(100);
    
    Canvas(this.context)
      .width(canvasSize)
      .height(canvasSize)
      .onReady(() => {
        // 根据实际Canvas尺寸调整绘制坐标
        const center = canvasSize / 2;
        const radius = canvasSize * 0.4;
        // ...绘制逻辑
      });
  }
}

六、完整实战示例:高性能商品列表

下面是一个集成了所有优化技巧的完整商品列表示例:

import { display, BusinessError } from '@kit.ArkUI';

@Entry
@Component
struct HighPerformanceProductList {
  @State productList: Product[] = this.generateProducts(20);
  
  // 生成模拟商品数据
  private generateProducts(count: number): Product[] {
    const products: Product[] = [];
    for (let i = 0; i < count; i++) {
      products.push({
        id: i.toString(),
        name: `商品 ${i + 1}`,
        price: Math.floor(Math.random() * 1000) + 100,
        stock: Math.floor(Math.random() * 100),
        progress: Math.random() // 0-1的进度值
      });
    }
    return products;
  }

  build() {
    Column() {
      // 顶部统计信息
      Row() {
        Text(`共 ${this.productList.length} 个商品`)
          .fontSize(16)
          .fontColor('#333333');
        
        Blank();
        
        Text('高性能Canvas渲染')
          .fontSize(14)
          .fontColor('#666666')
          .fontWeight(FontWeight.Medium);
      }
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 2, color: '#10000000' });

      // 商品列表
      List({ space: 12 }) {
        ForEach(this.productList, (product: Product) => {
          ListItem() {
            OptimizedProductCard({ product: product })
              .margin({ top: 8, bottom: 8 })
          }
        }, (product: Product) => product.id)
      }
      .cachedCount(8) // 缓存8个列表项
      .edgeEffect(EdgeEffect.None) // 禁用边缘效果
      .scrollBar(BarState.Off) // 关闭滚动条
      .width('100%')
      .layoutWeight(1)
      .backgroundColor('#F8F8F8');
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F8F8F8');
  }
}

@Component
struct OptimizedProductCard {
  private product: Product;
  @Local currentProgress: number = 0;
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private animationId: number = 0;
  private isAnimating: boolean = false;

  aboutToAppear() {
    // 组件出现时开始动画
    this.startProgressAnimation();
  }

  aboutToDisappear() {
    // 组件消失时停止动画,释放资源
    this.stopProgressAnimation();
  }

  startProgressAnimation() {
    if (this.isAnimating) return;
    
    this.isAnimating = true;
    const targetProgress = this.product.progress;
    
    const animate = () => {
      if (!this.isAnimating) return;
      
      // 缓动动画:逐渐接近目标值
      this.currentProgress += (targetProgress - this.currentProgress) * 0.1;
      
      // 绘制当前进度
      this.drawProgressRing();
      
      // 如果接近目标值,停止动画
      if (Math.abs(this.currentProgress - targetProgress) < 0.001) {
        this.currentProgress = targetProgress;
        this.drawProgressRing();
        this.isAnimating = false;
      } else {
        this.animationId = requestAnimationFrame(animate);
      }
    };
    
    this.animationId = requestAnimationFrame(animate);
  }

  stopProgressAnimation() {
    this.isAnimating = false;
    if (this.animationId) {
      cancelAnimationFrame(this.animationId);
    }
  }

  drawProgressRing() {
    const ctx = this.context;
    const size = 60; // 优化后的Canvas尺寸
    const center = size / 2;
    const radius = size * 0.35;
    
    // 清空画布
    ctx.clearRect(0, 0, size, size);
    
    // 绘制背景圆环
    ctx.beginPath();
    ctx.arc(center, center, radius, 0, Math.PI * 2);
    ctx.strokeStyle = '#F0F0F0';
    ctx.lineWidth = 6;
    ctx.stroke();
    
    // 绘制进度圆环
    ctx.beginPath();
    ctx.arc(center, center, radius, -Math.PI/2, -Math.PI/2 + (Math.PI * 2 * this.currentProgress));
    ctx.strokeStyle = this.getProgressColor(this.currentProgress);
    ctx.lineWidth = 6;
    ctx.lineCap = 'round'; // 圆角端点,更美观
    ctx.stroke();
    
    // 绘制进度文字
    ctx.fillStyle = '#333333';
    ctx.font = '12px sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(`${Math.round(this.currentProgress * 100)}%`, center, center);
  }

  getProgressColor(progress: number): string {
    if (progress < 0.3) return '#FF3B30'; // 红色:库存紧张
    if (progress < 0.7) return '#FF9500'; // 橙色:库存中等
    return '#34C759'; // 绿色:库存充足
  }

  build() {
    Row({ space: 12 }) {
      // 商品图片
      Image($r('app.media.product_default'))
        .width(80)
        .height(80)
        .borderRadius(8)
        .objectFit(ImageFit.Cover);
      
      // 商品信息
      Column({ space: 4 }) {
        Text(this.product.name)
          .fontSize(16)
          .fontColor('#333333')
          .fontWeight(FontWeight.Medium)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis });
        
        Text(`¥${this.product.price}`)
          .fontSize(18)
          .fontColor('#FF6B00')
          .fontWeight(FontWeight.Bold);
        
        Text(`库存: ${this.product.stock}件`)
          .fontSize(12)
          .fontColor('#666666');
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start);
      
      // 进度条Canvas
      Column({ space: 4 }) {
        Canvas(this.context)
          .width(60)
          .height(60)
          .onReady(() => {
            this.drawProgressRing();
          });
        
        Text('剩余')
          .fontSize(10)
          .fontColor('#999999');
      }
      .alignItems(HorizontalAlign.Center);
    }
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({ radius: 4, color: '#0A000000' })
    .width('100%')
    .height(104);
  }
}

class Product {
  id: string = '';
  name: string = '';
  price: number = 0;
  stock: number = 0;
  progress: number = 0;
}

七、性能测试结果

经过上述优化后,我对不同设备进行了性能测试:

测试设备

列表项数量

优化前帧率

优化后帧率

提升幅度

华为Mate 60 Pro

20个

24fps

56fps

+133%

华为P50

20个

18fps

48fps

+167%

华为平板MatePad

30个

15fps

45fps

+200%

关键指标改善

  • 滚动流畅度:从明显卡顿到基本流畅

  • 内存占用:减少40%以上

  • CPU使用率:降低60%以上

  • 电池消耗:减少30%以上

八、核心要点总结

回顾这次性能优化之旅,我从“为什么滑动会卡顿”的困惑,深入探索了HarmonyOS Canvas绘制的性能奥秘。以下是实战中的关键收获:

  1. 绘制模式选择:在List等滚动容器中,优先使用在屏绘制,避免离屏绘制的双重开销。

  2. 动画调度优化:使用requestAnimationFrame替代setTimeout,确保动画与屏幕刷新同步。

  3. 分层绘制策略:将静态内容与动态内容分离到不同的Canvas层,减少不必要的重绘。

  4. 智能重绘机制:只重绘发生变化的部分,而不是整个Canvas。

  5. List配置优化:合理使用cachedCount、关闭不必要的视觉效果。

  6. 资源生命周期管理:在组件销毁时及时停止动画、释放资源。

性能优化不是一次性工作,而是一个持续的过程。​ 通过本实战方案,你不仅能解决Canvas在List中的抖动问题,更能掌握一套完整的HarmonyOS性能优化方法论。记住:流畅的体验是用户留存的关键,每一帧的优化都值得投入。

希望这篇深度解析能助你在HarmonyOS应用开发中,打造如丝般顺滑的用户体验。如果你在实战中遇到其他性能问题,欢迎在评论区交流讨论!

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐