Hiển thị động danh sách trong HarmonyOS Next với ForEach

Trong môi trường phát triển HarmonyOS Next, thành phần ForEach là công cụ cốt lõi để hiển thị động các tập dữ liệu dạng mảng. Khi kết hợp với các container như List, Column, hay Grid, ForEach cho phép xây dựng giao diện linh hoạt và hiệu quả — đặc biệt phù hợp cho danh sách cuộn, giao diện skeleton, hoặc danh sách tương tác như sắp xếp kéo thả.

1. Cơ chế sinh khóa (key generation)

Mỗi phần tử được render bởi ForEach yêu cầu một giá trị khóa duy nhất để hệ thống theo dõi trạng thái và tối ưu hóa việc cập nhật UI.
  • Khóa mặc định: Nếu không cung cấp hàm keyGenerator, framework sử dụng hàm ẩn: (item, index) => `${index}__${JSON.stringify(item)}`. Cách này dễ gây xung đột khi dữ liệu có phần tử trùng lặp hoặc cấu trúc đơn giản.
  • Tùy chỉnh khóa: Luôn nên khai báo rõ ràng hàm keyGenerator để đảm bảo tính ổn định. Ví dụ: với đối tượng Product, dùng product.id thay vì index.
  • Cảnh báo quan trọng: Khóa trùng lặp sẽ kích hoạt cảnh báo runtime và gây ra hành vi không xác định — như mất trạng thái tương tác, nhảy vị trí, hoặc component không phản ứng đúng khi dữ liệu thay đổi.

2. Quy tắc tạo và tái sử dụng component

Khi dữ liệu thay đổi, ForEach không tạo lại toàn bộ danh sách mà so sánh khóa giữa lần render trước và hiện tại:

  • Nếu khóa mới chưa tồn tại → tạo component mới.
  • Nếu khóa đã tồn tại → tái sử dụng component hiện có (giữ nguyên trạng thái nội bộ, ví dụ: focus, scroll offset, hoặc biến cục bộ).

Dưới đây là ví dụ minh họa việc cập nhật giá trị phần tử mà không làm mất trạng thái giao diện:

@Entry
@Component
struct ProductList {
  @State items: string[] = ['A', 'B', 'C'];
  
  build() {
    Column() {
      Button('Cập nhật mục thứ ba')
        .onClick(() => {
          this.items[2] = 'C_updated';
        })
        .margin({ bottom: 16 })

      ForEach(this.items,
        (value: string) => ListItem() {
          ProductItem({ label: value })
        },
        (value: string) => value // khóa là giá trị chuỗi — chỉ an toàn nếu đảm bảo uniqueness
      )
    }
    .width('100%')
    .height('100%')
  }
}

@Component
struct ProductItem {
  @Prop label: string;
  build() {
    Text(this.label)
      .fontSize(28)
      .padding(12)
      .backgroundColor('#F9FAFB')
      .borderRadius(8)
  }
}

3. Các kịch bản sử dụng điển hình

a) Danh sách tĩnh (skeleton hoặc placeholder)

Khi dữ liệu chưa sẵn sàng (ví dụ: đang tải), dùng mảng số nguyên hoặc chuỗi cố định làm nguồn — khóa có thể là chỉ số hoặc giá trị:
@Builder
function SkeletonCard() {
  Row() {
    Column() {
      Rect().width(60).height(60).fill('#E5E7EB')
      Row() {
        Rect().width('60%').height(16).fill('#E5E7EB')
        Rect().width('40%').height(16).fill('#E5E7EB')
      }.space(8)
    }.margin({ right: 12 })
    
    Column() {
      Rect().width('80%').height(20).fill('#E5E7EB')
      Rect().width('60%').height(18).fill('#E5E7EB')
      Rect().width('50%').height(18).fill('#E5E7EB')
    }
  }
  .padding(16)
  .borderRadius(12)
  .backgroundColor('#F9FAFB')
  .height(100)
}

@Entry
@Component
struct LoadingView {
  @State placeholders: number[] = [0, 1, 2, 3, 4];
  build() {
    List() {
      ForEach(this.placeholders,
        () => ListItem() { SkeletonCard() },
        (idx: number) => `skeleton-${idx}`
      )
    }
    .padding(12)
  }
}

b) Danh sách động với thao tác chèn/xóa

Khi thực hiện push(), splice(), hoặc filter(), khóa phải dựa trên định danh vĩnh viễn — không phụ thuộc vào vị trí:
class Task {
  id: string;
  title: string;
  completed: boolean;
  constructor(id: string, title: string, completed: boolean = false) {
    this.id = id;
    this.title = title;
    this.completed = completed;
  }
}

@Entry
@Component
struct TaskBoard {
  @State tasks: Task[] = [
    new Task('task-1', 'Hoàn thành báo cáo'),
    new Task('task-2', 'Gửi email cho khách hàng'),
    new Task('task-3', 'Cập nhật tài liệu kỹ thuật')
  ];

  addTask() {
    const newId = `task-${Date.now()}`;
    this.tasks.push(new Task(newId, 'Nhiệm vụ mới'));
  }

  build() {
    Column() {
      Button('Thêm nhiệm vụ').onClick(() => this.addTask())
      
      List() {
        ForEach(this.tasks,
          (task: Task) => ListItem() {
            TaskItem({ task })
          },
          (task: Task) => task.id // khóa ổn định, độc lập với thứ tự
        )
      }
      .padding(8)
    }
  }
}

c) Phản ứng với thay đổi thuộc tính bên trong đối tượng

Để ForEach nhận biết khi một thuộc tính của đối tượng thay đổi (ví dụ: isLiked), cần kết hợp @Observed@ObjectLink:
@Observed
class Comment {
  id: string;
  content: string;
  likes: number;
  isLiked: boolean;

  constructor(id: string, content: string, likes: number = 0, isLiked: boolean = false) {
    this.id = id;
    this.content = content;
    this.likes = likes;
    this.isLiked = isLiked;
  }
}

@Component
struct CommentItem {
  @ObjectLink comment: Comment;

  toggleLike() {
    this.comment.isLiked = !this.comment.isLiked;
    this.comment.likes += this.comment.isLiked ? 1 : -1;
  }

  build() {
    Row() {
      Text(this.comment.content)
        .fontSize(16)
        .flexGrow(1)
      
      Button(this.comment.isLiked ? '❤️' : '♡')
        .fontSize(18)
        .onClick(() => this.toggleLike())
    }
    .padding(12)
    .borderRadius(8)
    .backgroundColor('#F9FAFB')
  }
}

d) Sắp xếp bằng thao tác kéo thả (drag-and-drop)

Khi đặt ForEach bên trong List và đăng ký sự kiện onMove, hệ thống hỗ trợ hoạt ảnh di chuyển mượt mà — điều kiện bắt buộc là khóa không thay đổi, chỉ thứ tự mảng được điều chỉnh:
@Entry
@Component
struct DraggableList {
  @State items: string[] = Array.from({ length: 8 }, (_, i) => `Mục ${i + 1}`);

  build() {
    List() {
      ForEach(this.items,
        (item: string) => ListItem() {
          Text(item)
            .fontSize(20)
            .textAlign(TextAlign.Center)
            .height(80)
            .width('100%')
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
        },
        (item: string) => item // khóa giữ nguyên dù thứ tự thay đổi
      )
      .onMove((from: number, to: number) => {
        const [moved] = this.items.splice(from, 1);
        if (from < to) {
          this.items.splice(to - 1, 0, moved);
        } else {
          this.items.splice(to, 0, moved);
        }
      })
    }
    .width('100%')
    .height('100%')
  }
}

4. Nguyên tắc thiết kế tốt

  • Luôn ưu tiên khóa dựa trên ID cố định (item.id) thay vì chỉ số (index) hoặc giá trị nguyên thủy dễ trùng lặp.
  • Với dữ liệu ban đầu là mảng nguyên thủy nhưng có khả năng thay đổi, hãy ánh xạ sang mảng đối tượng có ID riêng — ví dụ: ['A','B'] → [{id:'a1', value:'A'}, {id:'a2', value:'B'}].
  • Không lồng ghép ForEach với LazyForEach trong cùng một container — điều này gây xung đột cơ chế rendering và dẫn đến lỗi không xác định.

5. Những vấn đề thường gặp

  • Component bị "reset" sau thay đổi dữ liệu: Thường do khóa không ổn định — kiểm tra lại hàm keyGenerator có trả về giá trị duy nhất và bất biến cho mỗi phần tử.
  • Hiệu năng chậm khi thêm/xóa phần tử ở đầu mảng: Xảy ra khi dùng index làm khóa — toàn bộ phần tử phía sau đều bị coi là "mới", dẫn đến tái tạo không cần thiết. Giải pháp: dùng ID.
  • Animation không mượt khi kéo thả: Đảm bảo onMove chỉ điều chỉnh thứ tự mảng, không tạo lại đối tượng hay thay đổi khóa.

Thẻ: harmonyos-next forEach ui-rendering ArkTS list-component

Đăng vào ngày 8 tháng 6 lúc 20:15