HarmonyOS开发:社交动态发布与展示

📌 核心要点:社交动态流是社交App的核心,图文/视频动态发布、点赞/评论/转发交互、动态列表性能优化三大难题,懒加载和虚拟列表是性能关键。

背景与动机

刷朋友圈、刷微博、刷小红书——你每天花多少时间在"刷动态"上?

社交动态流看起来就是一列卡片,每个卡片有图片、文字、点赞按钮。但真要做起来,坑一个接一个。图片多了列表卡顿怎么办?视频自动播放怎么控制?点赞要实时反馈还是等服务端确认?评论列表怎么分页?转发动态怎么展示原动态?动态列表滑到1000条时内存爆了怎么办?

社交动态的难点不在UI,在性能和交互。动态列表可能无限长,每条动态可能有9张图、几十条评论、几百个赞。你用ForEach渲染1000条动态试试?内存直接飙到2GB,App闪退。

更别提交互细节:点赞动画要丝滑、评论要实时更新、图片要懒加载、视频要自动播放但只播可见区域——每个细节都影响用户体验。

这篇文章把社交动态的发布、展示、交互、性能优化全拆开讲。

核心原理

社交动态流的核心是虚拟列表+懒加载+增量更新。只渲染可见区域的动态,图片按需加载,交互操作乐观更新。

A[用户打开动态流] --> B[加载首页动态列表]
B --> C[虚拟列表渲染可见区域]

C --> D{用户操作}

D -->|下拉刷新| E[请求最新动态]
E --> F[新动态插入列表头部]

D -->|上滑加载| G[请求更多历史动态]
G --> H[旧动态追加到列表尾部]

D -->|点击点赞| I[乐观更新UI]
I --> J[发送点赞请求]
J --> K{服务端结果}
K -->|成功| L[保持UI不变]
K -->|失败| M[回滚UI状态]

D -->|发布动态| N[上传图片/视频]
N --> O[创建动态]
O --> P[插入列表头部]

D -->|查看图片| Q[全屏图片查看器]

动态数据模型

一条动态包含:发布者信息、内容(文字/图片/视频)、互动数据(点赞/评论/转发)、时间戳。

动态类型决定了渲染方式:

  • 纯文字:只渲染文字,最轻量
  • 图文:1-9张图片,用九宫格布局
  • 视频:视频封面+播放器
  • 转发:嵌套展示原动态

性能优化策略

策略 说明
虚拟列表 只渲染可见区域的动态,不可见的回收
图片懒加载 图片进入可见区域时才加载
分页加载 每次加载20条,上滑触底加载更多
增量更新 点赞/评论只更新对应动态,不刷新整个列表
图片缓存 已加载的图片缓存到内存,滑动回来时直接显示

代码实战

基础用法:动态列表展示

先搞定动态列表的核心:数据模型、列表渲染、九宫格图片。

// SocialFeed.ets — 社交动态流

// 动态类型
enum FeedType {
  TEXT = 'TEXT',           // 纯文字
  IMAGE = 'IMAGE',         // 图文
  VIDEO = 'VIDEO',         // 视频
  REPOST = 'REPOST',       // 转发
}

// 动态数据
interface FeedItem {
  id: string
  type: FeedType
  userId: string
  userName: string
  avatarUrl: string
  content: string           // 文字内容
  images: string[]          // 图片URL列表(最多9张)
  videoUrl?: string         // 视频URL
  videoCoverUrl?: string    // 视频封面
  likeCount: number         // 点赞数
  commentCount: number      // 评论数
  repostCount: number       // 转发数
  isLiked: boolean          // 当前用户是否已点赞
  createTime: string
  location?: string         // 位置
  originalFeed?: FeedItem   // 转发的原动态
}

@Entry
@Component
struct SocialFeedPage {
  @State feedList: FeedItem[] = []
  @State isLoading: boolean = false
  @State isLoadingMore: boolean = false
  @State hasMore: boolean = true

  aboutToAppear() {
    this.loadFeedList()
  }

  build() {
    Column() {
      // 顶部导航
      Row() {
        Text('动态')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')

        Blank()

        Image($r('app.media.ic_camera'))
          .width(24)
          .height(24)
          .fillColor('#333333')
          .onClick(() => {
            // 跳转发布页
          })
      }
      .width('100%')
      .height(48)
      .padding({ left: 16, right: 16 })
      .backgroundColor(Color.White)

      // 动态列表
      Refresh({ refreshing: $$this.isLoading }) {
        List() {
          ForEach(this.feedList, (item: FeedItem) => {
            ListItem() {
              this.FeedCard(item)
            }
          }, (item: FeedItem) => item.id)

          // 加载更多
          ListItem() {
            this.LoadMoreFooter()
          }
        }
        .onReachEnd(() => {
          if (this.hasMore && !this.isLoadingMore) {
            this.loadMoreFeed()
          }
        })
        .scrollBar(BarState.Off)
        .cachedCount(3)  // 预渲染3个屏幕外的Item
      }
      .onRefreshing(() => {
        this.refreshFeed()
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  // ========== 动态卡片 ==========
  @Builder
  FeedCard(item: FeedItem) {
    Column() {
      // 用户信息行
      Row() {
        Image(item.avatarUrl)
          .width(40)
          .height(40)
          .borderRadius(20)
          .objectFit(ImageFit.Cover)

        Column() {
          Text(item.userName)
            .fontSize(15)
            .fontWeight(FontWeight.Medium)
            .fontColor('#333333')

          if (item.location) {
            Text(item.location)
              .fontSize(11)
              .fontColor('#999999')
              .margin({ top: 2 })
          }
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })

        Blank()

        Text(this.formatTime(item.createTime))
          .fontSize(12)
          .fontColor('#999999')
      }
      .width('100%')

      // 文字内容
      if (item.content) {
        Text(item.content)
          .fontSize(15)
          .fontColor('#333333')
          .maxLines(6)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ top: 8 })
          .width('100%')
      }

      // 图片九宫格
      if (item.type === FeedType.IMAGE && item.images.length > 0) {
        this.ImageGrid(item.images)
      }

      // 视频封面
      if (item.type === FeedType.VIDEO && item.videoCoverUrl) {
        this.VideoCover(item)
      }

      // 转发原动态
      if (item.type === FeedType.REPOST && item.originalFeed) {
        this.RepostContent(item.originalFeed)
      }

      // 互动栏
      this.ActionBar(item)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .margin({ bottom: 8 })
  }

  // ========== 九宫格图片 ==========
  @Builder
  ImageGrid(images: string[]) {
    const count = images.length
    // 根据图片数量决定布局
    if (count === 1) {
      // 单张大图
      Image(images[0])
        .width('100%')
        .height(200)
        .borderRadius(8)
        .objectFit(ImageFit.Cover)
        .margin({ top: 8 })
    } else if (count <= 3) {
      // 横向排列
      Row() {
        ForEach(images, (url: string, index?: number) => {
          Image(url)
            .width(count === 2 ? '48%' : '31%')
            .aspectRatio(1)
            .borderRadius(4)
            .objectFit(ImageFit.Cover)
            .margin({ right: (index ?? 0) < count - 1 ? 4 : 0 })
        }, (url: string, index?: number) => `${url}_${index}`)
      }
      .margin({ top: 8 })
    } else {
      // 九宫格
      Grid() {
        ForEach(images, (url: string) => {
          GridItem() {
            Image(url)
              .width('100%')
              .aspectRatio(1)
              .borderRadius(2)
              .objectFit(ImageFit.Cover)
          }
        }, (url: string, index?: number) => `${url}_${index}`)
      }
      .columnsTemplate(count <= 2 ? '1fr 1fr' : '1fr 1fr 1fr')
      .rowsGap(4)
      .columnsGap(4)
      .margin({ top: 8 })
    }
  }

  // ========== 视频封面 ==========
  @Builder
  VideoCover(item: FeedItem) {
    Stack() {
      Image(item.videoCoverUrl)
        .width('100%')
        .height(200)
        .borderRadius(8)
        .objectFit(ImageFit.Cover)

      // 播放按钮
      Image($r('app.media.ic_play_circle'))
        .width(48)
        .height(48)
        .fillColor('#80FFFFFF')
    }
    .width('100%')
    .height(200)
    .margin({ top: 8 })
    .onClick(() => {
      // 播放视频
    })
  }

  // ========== 转发内容 ==========
  @Builder
  RepostContent(original: FeedItem) {
    Column() {
      Text(`@${original.userName}`)
        .fontSize(13)
        .fontColor('#1DA1F2')
        .fontWeight(FontWeight.Medium)

      if (original.content) {
        Text(original.content)
          .fontSize(13)
          .fontColor('#666666')
          .maxLines(3)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ top: 4 })
      }

      if (original.images.length > 0) {
        Row() {
          ForEach(original.images.slice(0, 3), (url: string) => {
            Image(url)
              .width(48)
              .height(48)
              .borderRadius(2)
              .objectFit(ImageFit.Cover)
              .margin({ right: 4 })
          }, (url: string, index?: number) => `${url}_${index}`)
        }
        .margin({ top: 4 })
      }
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#F5F5F5')
    .borderRadius(8)
    .margin({ top: 8 })
    .alignItems(HorizontalAlign.Start)
  }

  // ========== 互动栏 ==========
  @Builder
  ActionBar(item: FeedItem) {
    Row() {
      // 点赞
      Row() {
        Image(item.isLiked ? $r('app.media.ic_liked') : $r('app.media.ic_like'))
          .width(20)
          .height(20)
          .fillColor(item.isLiked ? '#FF4444' : '#999999')

        if (item.likeCount > 0) {
          Text(this.formatCount(item.likeCount))
            .fontSize(12)
            .fontColor(item.isLiked ? '#FF4444' : '#999999')
            .margin({ left: 4 })
        }
      }
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.toggleLike(item)
      })

      // 评论
      Row() {
        Image($r('app.media.ic_comment'))
          .width(20)
          .height(20)
          .fillColor('#999999')

        if (item.commentCount > 0) {
          Text(this.formatCount(item.commentCount))
            .fontSize(12)
            .fontColor('#999999')
            .margin({ left: 4 })
        }
      }
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)

      // 转发
      Row() {
        Image($r('app.media.ic_repost'))
          .width(20)
          .height(20)
          .fillColor('#999999')

        if (item.repostCount > 0) {
          Text(this.formatCount(item.repostCount))
            .fontSize(12)
            .fontColor('#999999')
            .margin({ left: 4 })
        }
      }
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .height(40)
    .margin({ top: 8 })
  }

  // ========== 加载更多底部 ==========
  @Builder
  LoadMoreFooter() {
    Row() {
      if (this.isLoadingMore) {
        LoadingProgress()
          .width(20)
          .height(20)
          .color('#999999')
        Text('加载中...')
          .fontSize(13)
          .fontColor('#999999')
          .margin({ left: 8 })
      } else if (!this.hasMore) {
        Text('— 没有更多了 —')
          .fontSize(13)
          .fontColor('#CCCCCC')
      }
    }
    .width('100%')
    .height(48)
    .justifyContent(FlexAlign.Center)
  }

  // ========== 点赞(乐观更新) ==========
  toggleLike(item: FeedItem) {
    // 乐观更新:先改UI,再发请求
    const wasLiked = item.isLiked
    item.isLiked = !item.isLiked
    item.likeCount += wasLiked ? -1 : 1

    // 触发UI刷新
    this.feedList = [...this.feedList]

    // 发送请求
    this.sendLikeRequest(item.id, !wasLiked).catch(() => {
      // 请求失败,回滚UI
      item.isLiked = wasLiked
      item.likeCount += wasLiked ? 1 : -1
      this.feedList = [...this.feedList]
    })
  }

  async sendLikeRequest(feedId: string, isLike: boolean): Promise<void> {
    // 实际项目:const response = await http.post('/api/feed/like', { feedId, isLike })
    console.info(`[SocialFeed] 点赞: ${feedId}, ${isLike}`)
  }

  // ========== 辅助方法 ==========
  formatCount(count: number): string {
    if (count >= 10000) return `${(count / 10000).toFixed(1)}`
    if (count >= 1000) return `${(count / 1000).toFixed(1)}k`
    return `${count}`
  }

  formatTime(time: string): string {
    // 简化实现:实际需要计算时间差
    return '3分钟前'
  }

  async loadFeedList() {
    this.feedList = this.getMockFeedList()
  }

  async refreshFeed() {
    const newFeeds = this.getMockFeedList()
    this.feedList = newFeeds
    this.hasMore = true
  }

  async loadMoreFeed() {
    this.isLoadingMore = true
    // 模拟加载更多
    setTimeout(() => {
      this.feedList = [...this.feedList, ...this.getMockFeedList()]
      this.isLoadingMore = false
    }, 1000)
  }

  private getMockFeedList(): FeedItem[] {
    return [
      {
        id: `feed_${Date.now()}_1`, type: FeedType.IMAGE,
        userId: 'u1', userName: '设计师小王', avatarUrl: 'https://picsum.photos/80/80?random=1',
        content: '今天的新作品,灵感来自大自然的色彩渐变 🎨', 
        images: ['https://picsum.photos/400/400?random=1', 'https://picsum.photos/400/400?random=2', 'https://picsum.photos/400/400?random=3'],
        likeCount: 128, commentCount: 32, repostCount: 15, isLiked: false,
        createTime: '2024-12-25 10:30:00', location: '北京·798艺术区'
      },
      {
        id: `feed_${Date.now()}_2`, type: FeedType.TEXT,
        userId: 'u2', userName: '产品经理老李', avatarUrl: 'https://picsum.photos/80/80?random=2',
        content: '做产品最重要的是什么?不是功能多,不是技术强,而是真正理解用户的需求。很多时候用户说想要一匹更快的马,其实他需要的是一辆车。',
        images: [], likeCount: 256, commentCount: 48, repostCount: 67, isLiked: true,
        createTime: '2024-12-25 09:15:00'
      },
      {
        id: `feed_${Date.now()}_3`, type: FeedType.VIDEO,
        userId: 'u3', userName: '旅行达人小张', avatarUrl: 'https://picsum.photos/80/80?random=3',
        content: '冰岛极光,这辈子一定要看一次!',
        images: [], videoUrl: '', videoCoverUrl: 'https://picsum.photos/600/300?random=4',
        likeCount: 1024, commentCount: 156, repostCount: 89, isLiked: false,
        createTime: '2024-12-25 08:00:00', location: '冰岛·雷克雅未克'
      },
    ]
  }
}

进阶用法:动态发布

动态发布支持图文和视频,核心是图片/视频选择和上传。

// FeedPublishPage.ets — 动态发布页
import { router } from '@kit.ArkUI'
import { photoAccessHelper } from '@kit.MediaLibraryKit'
import { promptAction } from '@kit.ArkUI'

@Entry
@Component
struct FeedPublishPage {
  @State content: string = ''
  @State selectedImages: string[] = []
  @State maxImageCount: number = 9
  @State isPublishing: boolean = false
  @State location: string = ''

  build() {
    Column() {
      // 顶部操作栏
      Row() {
        Text('取消')
          .fontSize(16)
          .fontColor('#666666')
          .onClick(() => { router.back() })

        Blank()

        Button('发布')
          .fontSize(14)
          .fontColor(Color.White)
          .backgroundColor(this.canPublish() ? '#1DA1F2' : '#CCCCCC')
          .borderRadius(16)
          .width(64)
          .height(32)
          .enabled(this.canPublish())
          .onClick(() => {
            this.publishFeed()
          })
      }
      .width('100%')
      .height(48)
      .padding({ left: 16, right: 16 })
      .backgroundColor(Color.White)

      // 内容输入区
      TextArea({
        placeholder: '分享你的想法...'
      })
        .width('100%')
        .height(160)
        .fontSize(16)
        .fontColor('#333333')
        .padding(16)
        .backgroundColor(Color.White)
        .onChange((value: string) => {
          this.content = value
        })

      // 图片预览区
      if (this.selectedImages.length > 0) {
        this.ImagePreviewGrid()
      }

      Divider().color('#F0F0F0')

      // 底部工具栏
      Row() {
        // 图片选择
        Image($r('app.media.ic_image'))
          .width(28)
          .height(28)
          .fillColor('#666666')
          .onClick(() => {
            this.pickImages()
          })

        // 视频选择
        Image($r('app.media.ic_video'))
          .width(28)
          .height(28)
          .fillColor('#666666')
          .margin({ left: 24 })

        // 位置
        Image($r('app.media.ic_location'))
          .width(28)
          .height(28)
          .fillColor('#666666')
          .margin({ left: 24 })
          .onClick(() => {
            this.location = '北京·朝阳区'
          })

        Blank()

        // 字数统计
        Text(`${this.content.length}/2000`)
          .fontSize(12)
          .fontColor(this.content.length > 2000 ? '#FF4444' : '#999999')
      }
      .width('100%')
      .height(48)
      .padding({ left: 16, right: 16 })
      .backgroundColor(Color.White)
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.White)
  }

  // ========== 图片预览九宫格 ==========
  @Builder
  ImagePreviewGrid() {
    Grid() {
      ForEach(this.selectedImages, (url: string, index?: number) => {
        GridItem() {
          Stack() {
            Image(url)
              .width('100%')
              .aspectRatio(1)
              .borderRadius(4)
              .objectFit(ImageFit.Cover)

            // 删除按钮
            Image($r('app.media.ic_close'))
              .width(20)
              .height(20)
              .fillColor(Color.White)
              .backgroundColor('#80000000')
              .borderRadius(10)
              .position({ x: '75%', y: 0 })
              .onClick(() => {
                const idx = index ?? 0
                this.selectedImages.splice(idx, 1)
                this.selectedImages = [...this.selectedImages]
              })
          }
        }
      }, (url: string, index?: number) => `${url}_${index}`)

      // 添加图片按钮
      if (this.selectedImages.length < this.maxImageCount) {
        GridItem() {
          Column() {
            Image($r('app.media.ic_add'))
              .width(28)
              .height(28)
              .fillColor('#CCCCCC')

            Text('添加图片')
              .fontSize(11)
              .fontColor('#999999')
              .margin({ top: 4 })
          }
          .width('100%')
          .aspectRatio(1)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#F5F5F5')
          .borderRadius(4)
          .onClick(() => {
            this.pickImages()
          })
        }
      }
    }
    .columnsTemplate('1fr 1fr 1fr')
    .rowsGap(4)
    .columnsGap(4)
    .width('100%')
    .padding(16)
  }

  // ========== 选择图片 ==========
  async pickImages() {
    try {
      const remaining = this.maxImageCount - this.selectedImages.length
      if (remaining <= 0) {
        promptAction.showToast({ message: `最多选择${this.maxImageCount}张图片` })
        return
      }

      // 实际项目:调用系统图片选择器
      // const picker = new photoAccessHelper.PhotoViewPicker()
      // const result = await picker.select({
      //   MIMEType: photoAccessHelper.PhotoViewMIMEType.IMAGE_TYPE,
      //   maxSelectNumber: remaining
      // })
      // const newImages = result.photoUris
      // this.selectedImages = [...this.selectedImages, ...newImages]

      // 模拟添加图片
      const mockImages = [
        'https://picsum.photos/400/400?random=10',
        'https://picsum.photos/400/400?random=11',
      ]
      this.selectedImages = [...this.selectedImages, ...mockImages.slice(0, remaining)]
    } catch (error) {
      console.error(`[FeedPublish] 选择图片失败: ${JSON.stringify(error)}`)
    }
  }

  // ========== 发布动态 ==========
  async publishFeed() {
    if (!this.canPublish()) return
    this.isPublishing = true

    try {
      // 1. 上传图片(如果有)
      let imageUrls: string[] = []
      if (this.selectedImages.length > 0) {
        imageUrls = await this.uploadImages(this.selectedImages)
      }

      // 2. 创建动态
      const feedData = {
        content: this.content,
        images: imageUrls,
        location: this.location,
      }

      // 实际项目:const response = await http.post('/api/feed/create', feedData)
      console.info('[FeedPublish] 动态发布成功')

      promptAction.showToast({ message: '发布成功' })
      router.back()
    } catch (error) {
      promptAction.showToast({ message: '发布失败,请重试' })
    } finally {
      this.isPublishing = false
    }
  }

  async uploadImages(localPaths: string[]): Promise<string[]> {
    // 实际项目:上传到云存储,返回CDN URL
    return localPaths
  }

  canPublish(): boolean {
    return this.content.trim().length > 0 || this.selectedImages.length > 0
  }
}

完整示例:高性能动态流

把懒加载、虚拟列表、增量更新串成完整的高性能动态流。

// HighPerfFeed.ets — 高性能社交动态流
import { router } from '@kit.ArkUI'

@Entry
@Component
struct HighPerfFeed {
  @State feedList: FeedItem[] = []
  @State isLoading: boolean = false
  @State isLoadingMore: boolean = false
  @State hasMore: boolean = true
  @State currentPage: number = 1

  private listScroller: Scroller = new Scroller()
  private pageSize: number = 20

  aboutToAppear() {
    this.loadFeedList(1)
  }

  build() {
    Column() {
      // 顶部栏
      Row() {
        Text('动态')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Blank()
        Image($r('app.media.ic_camera'))
          .width(24)
          .height(24)
          .fillColor('#333333')
      }
      .width('100%')
      .height(48)
      .padding({ left: 16, right: 16 })
      .backgroundColor(Color.White)

      Refresh({ refreshing: $$this.isLoading }) {
        List({ scroller: this.listScroller }) {
          // 使用LazyForEach实现虚拟列表
          LazyForEach(new FeedDataSource(this.feedList), (item: FeedItem) => {
            ListItem() {
              this.FeedCard(item)
            }
          }, (item: FeedItem) => item.id)

          ListItem() {
            Row() {
              if (this.isLoadingMore) {
                LoadingProgress().width(20).height(20).color('#999999')
                Text('加载中...').fontSize(13).fontColor('#999999').margin({ left: 8 })
              } else if (!this.hasMore) {
                Text('— 没有更多了 —').fontSize(13).fontColor('#CCCCCC')
              }
            }
            .width('100%')
            .height(48)
            .justifyContent(FlexAlign.Center)
          }
        }
        .cachedCount(5)  // 预缓存5个Item
        .scrollBar(BarState.Off)
        .onReachEnd(() => {
          if (this.hasMore && !this.isLoadingMore) {
            this.loadFeedList(this.currentPage + 1)
          }
        })
      }
      .onRefreshing(() => {
        this.refreshFeed()
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  // ========== 动态卡片 ==========
  @Builder
  FeedCard(item: FeedItem) {
    Column() {
      Row() {
        Image(item.avatarUrl)
          .width(40)
          .height(40)
          .borderRadius(20)
          .objectFit(ImageFit.Cover)
        Column() {
          Text(item.userName).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#333333')
          if (item.location) {
            Text(item.location).fontSize(11).fontColor('#999999').margin({ top: 2 })
          }
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })
        Blank()
        Text(this.formatTime(item.createTime)).fontSize(12).fontColor('#999999')
      }

      if (item.content) {
        Text(item.content).fontSize(15).fontColor('#333333').maxLines(6)
          .textOverflow({ overflow: TextOverflow.Ellipsis }).margin({ top: 8 }).width('100%')
      }

      if (item.type === FeedType.IMAGE && item.images.length > 0) {
        this.ImageGrid(item.images)
      }

      // 互动栏
      Row() {
        Row() {
          Image(item.isLiked ? $r('app.media.ic_liked') : $r('app.media.ic_like'))
            .width(20).height(20).fillColor(item.isLiked ? '#FF4444' : '#999999')
          if (item.likeCount > 0) {
            Text(this.formatCount(item.likeCount)).fontSize(12)
              .fontColor(item.isLiked ? '#FF4444' : '#999999').margin({ left: 4 })
          }
        }.layoutWeight(1).justifyContent(FlexAlign.Center)
        .onClick(() => { this.toggleLike(item) })

        Row() {
          Image($r('app.media.ic_comment')).width(20).height(20).fillColor('#999999')
          if (item.commentCount > 0) {
            Text(this.formatCount(item.commentCount)).fontSize(12).fontColor('#999999').margin({ left: 4 })
          }
        }.layoutWeight(1).justifyContent(FlexAlign.Center)

        Row() {
          Image($r('app.media.ic_repost')).width(20).height(20).fillColor('#999999')
          if (item.repostCount > 0) {
            Text(this.formatCount(item.repostCount)).fontSize(12).fontColor('#999999').margin({ left: 4 })
          }
        }.layoutWeight(1).justifyContent(FlexAlign.Center)
      }
      .width('100%').height(40).margin({ top: 8 })
    }
    .width('100%').padding(16).backgroundColor(Color.White).margin({ bottom: 8 })
  }

  @Builder
  ImageGrid(images: string[]) {
    if (images.length === 1) {
      Image(images[0]).width('100%').height(200).borderRadius(8).objectFit(ImageFit.Cover).margin({ top: 8 })
    } else {
      Grid() {
        ForEach(images, (url: string) => {
          GridItem() {
            Image(url).width('100%').aspectRatio(1).borderRadius(2).objectFit(ImageFit.Cover)
          }
        }, (url: string, index?: number) => `${url}_${index}`)
      }
      .columnsTemplate('1fr 1fr 1fr').rowsGap(4).columnsGap(4).margin({ top: 8 })
    }
  }

  toggleLike(item: FeedItem) {
    const wasLiked = item.isLiked
    item.isLiked = !wasLiked
    item.likeCount += wasLiked ? -1 : 1
    this.feedList = [...this.feedList]
  }

  formatCount(count: number): string {
    if (count >= 10000) return `${(count / 10000).toFixed(1)}`
    return `${count}`
  }

  formatTime(time: string): string { return '3分钟前' }

  async refreshFeed() {
    this.currentPage = 1
    this.hasMore = true
    this.feedList = this.getMockFeedList()
  }

  async loadFeedList(page: number) {
    if (page === 1) {
      this.feedList = this.getMockFeedList()
    } else {
      this.isLoadingMore = true
      setTimeout(() => {
        this.feedList = [...this.feedList, ...this.getMockFeedList()]
        this.isLoadingMore = false
      }, 500)
    }
    this.currentPage = page
  }

  private getMockFeedList(): FeedItem[] {
    return Array.from({ length: 5 }, (_, i) => ({
      id: `feed_${Date.now()}_${this.currentPage}_${i}`,
      type: i % 3 === 0 ? FeedType.TEXT : FeedType.IMAGE,
      userId: `u${i}`, userName: `用户${i + 1}`,
      avatarUrl: `https://picsum.photos/80/80?random=${Date.now() + i}`,
      content: `这是第${this.currentPage}页第${i + 1}条动态,分享一些有趣的事情...`,
      images: i % 3 !== 0 ? [
        `https://picsum.photos/400/400?random=${Date.now() + i * 10 + 1}`,
        `https://picsum.photos/400/400?random=${Date.now() + i * 10 + 2}`,
        `https://picsum.photos/400/400?random=${Date.now() + i * 10 + 3}`,
      ] : [],
      likeCount: Math.round(Math.random() * 500),
      commentCount: Math.round(Math.random() * 100),
      repostCount: Math.round(Math.random() * 50),
      isLiked: Math.random() > 0.5,
      createTime: '2024-12-25 10:30:00',
    }))
  }
}

// LazyForEach数据源
class FeedDataSource implements IDataSource {
  private dataList: FeedItem[] = []
  private listeners: DataChangeListener[] = []

  constructor(data: FeedItem[]) {
    this.dataList = data
  }

  totalCount(): number { return this.dataList.length }
  getData(index: number): FeedItem { return this.dataList[index] }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) this.listeners.push(listener)
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener)
    if (pos >= 0) this.listeners.splice(pos, 1)
  }

  updateData(data: FeedItem[]): void {
    this.dataList = data
    this.listeners.forEach(l => l.onDataReloaded())
  }
}

踩坑与注意事项

坑1:动态列表滑动卡顿

100条动态,每条9张图,总共900张图片同时加载——内存爆了,滑动卡成PPT。

解决方案

  • LazyForEach替代ForEach,只渲染可见区域
  • 设置cachedCount,预缓存3-5个Item
  • 图片用缩略图,点击后加载原图
  • 列表项用@Reusable装饰器复用

坑2:九宫格图片布局计算

1张图、2张图、4张图、9张图——每种数量的布局都不一样。你用if-else写9种布局?那代码可读性为零。

解决方案:统一用3列Grid,1张图时单独处理(大图),其余都用3列。4张图时第二行只占1格,5张图时第二行占2格——Grid自动处理。

坑3:点赞乐观更新失败回滚

用户点了赞,UI立刻变了,但服务端返回失败——这时候UI要回滚,但用户已经看到了"已点赞"的状态,突然变回去很突兀。

解决方案:加个短暂的延迟,如果200ms内服务端返回成功就不回滚,如果失败再回滚。或者干脆不回滚,失败时弹个Toast提示"操作失败"。

坑4:图片上传顺序错乱

用户选了5张图,上传时3号图先传完,1号图还在传——动态发布后图片顺序不对。

解决方案:按选择顺序上传,用Promise.all等待所有图片上传完成后再发布。或者每张图片上传时记录原始序号,发布时按序号排序。

坑5:动态列表数据更新导致滚动位置跳动

下拉刷新时新数据插入列表头部,当前浏览位置突然跳到顶部。

解决方案:下拉刷新后保持滚动位置。新数据插入头部后,用scroller.scrollToIndex跳回原来的位置。

HarmonyOS 6适配说明

HarmonyOS 6对社交动态相关组件做了以下更新:

  1. LazyForEach性能优化:LazyForEach新增了prefetchCount属性,可以预取数据。用户滑到第15条动态时,第16-20条的数据已经预取好了,不再有加载延迟。

  2. Image组件解码优化:Image新增autoResize属性,自动根据显示尺寸解码图片。九宫格里的缩略图只需要150x150,不再加载4000x3000的原图再缩放,内存占用减少90%。

  3. Grid自适应布局:Grid新增了layoutDirectionadaptiveColumns属性,九宫格布局可以根据屏幕宽度自动调整列数,不用手动计算。

  4. 视频自动播放控制:Video组件新增了autoPlayPolicy属性,可以设置为"仅WiFi自动播放"或"仅可见区域自动播放",避免流量偷跑。

  5. @Reusable组件复用:新增@Reusable装饰器,标记的组件在滑出屏幕后不会销毁,而是放入复用池。新动态滑入时从复用池取组件,只更新数据,不需要重新创建。性能提升约60%。

总结

社交动态流的核心是性能。动态列表可能无限长,每条动态可能有9张图,你用ForEach全部渲染,内存和CPU都扛不住。

核心记住三点:

  • LazyForEach是必须的,只渲染可见区域,配合cachedCount预缓存
  • 点赞用乐观更新,先改UI再发请求,失败时回滚
  • 图片用缩略图,列表里显示缩略图,点击后加载原图,内存占用降90%
评估维度 说明
学习难度 ⭐⭐⭐⭐ 九宫格布局和性能优化需要经验
使用频率 ⭐⭐⭐⭐⭐ 社交App的核心功能
重要程度 ⭐⭐⭐⭐ 动态流卡顿直接导致用户流失

动态列表滑到第100条时App闪退了——这不是bug,这是用户卸载的理由。

Logo

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

更多推荐