系列生态拓展篇·第39篇。DevOps篇后,有产品经理问:“功能、测试、上线都搞定了,怎么让用户在手机桌面上就能直接复购?怎么让我们的服务出现在华为的负一屏、小艺建议里?” 这涉及到鸿蒙生态的流量入口设计。今天我们将电商Demo的服务卡片(Service Widget)升级为万能卡片(Universal Card),接入小艺建议、智慧搜索、场景感知三大系统级入口,实现“服务找人”而非“人找服务”。全程基于API23,含官方文档未涉及的“卡片动态刷新策略”和“意图框架(Intent Framework)接入技巧”。

一、前言:为什么“万能卡片”是鸿蒙生态的流量密码?

在传统App时代,用户与应用的互动是“孤岛式”的:打开App -> 寻找功能 -> 使用 -> 关闭。而在鸿蒙生态中,服务卡片打破了这种孤岛,让服务可以脱离App独立存在,分布在手机的各个角落:

  • 桌面卡片:用户一眼看到物流进度、优惠券。

  • 负一屏(智慧助手):聚合高频服务,如“待办事项”、“快递动态”。

  • 小艺建议:基于场景预测用户需求,主动推荐服务(如“下班时点外卖”)。

  • 智慧搜索:全局搜索时,直接展示服务卡片,而非只是App名称。

核心价值零层级触达。用户不需要打开App,在桌面上就能完成核心操作(如加购、查物流)。这极大地提升了用户体验和转化率。

今天我们将把电商Demo的“物流跟踪卡片”升级为一个智能、动态、可交互的万能卡片,并让它学会“察言观色”,在合适的时机主动出现。

二、核心概念辨析(服务卡片 vs 万能卡片)

特性

服务卡片 (Service Widget)

万能卡片 (Universal Card)

定义

应用提供的、可固定在桌面的UI组件

服务卡片的高级形态,深度融入系统级入口

交互性

支持简单点击、刷新

支持复杂交互、动态数据、场景感知

入口

桌面、文件夹

桌面、负一屏、小艺建议、搜索、锁屏

智能化

静态展示为主

基于用户习惯、时间、位置智能推荐

开发难度

较低,主要编写UI

较高,需接入意图框架、场景感知

三、代码实现:从“静态展示”到“智能服务”

3.1 卡片UI升级:动态物流卡片

首先,升级之前的物流卡片,使其支持实时刷新交互

修改entry/src/main/ets/widget/pages/LogisticsCard.ets



import { logistics } from '../../common/LogisticsUtil'
import { distributedDataObject } from '@kit.ArkData'
import { systemTimer } from '@kit.SystemTimerKit' // 引入定时器

@Entry
@Component
struct LogisticsCard {
  @State logisticsInfo: string = '加载中...'
  @State deliveryProgress: number = 0
  @State arrivalTime: string = '--'
  @State courierPhone: string = '' // 新增:快递员电话
  private ddo: distributedDataObject.DistributedDataObject = null!
  private timerId: number = -1 // 定时器ID

  aboutToAppear(): void {
    this.initData()
    this.startAutoRefresh()
  }

  aboutToDisappear(): void {
    this.stopAutoRefresh()
  }

  // 初始化数据
  async initData(): Promise<void> {
    // ... 之前的DDO初始化和数据加载逻辑 ...
    const info = await logistics.getLatestLogistics('latest')
    this.logisticsInfo = info.status
    this.deliveryProgress = info.progress
    this.arrivalTime = info.arrivalTime
    this.courierPhone = info.courierPhone || ''
  }

  // 启动自动刷新(每30秒一次)
  startAutoRefresh(): void {
    this.timerId = systemTimer.setInterval(() => {
      this.initData()
      console.log('卡片数据自动刷新')
    }, 30000) // 30秒
  }

  // 停止自动刷新
  stopAutoRefresh(): void {
    if (this.timerId !== -1) {
      systemTimer.clearInterval(this.timerId)
    }
  }

  // 用户点击卡片,跳转到手机App详情页
  navigateToDetail(): void {
    postCardAction(this, {
      action: 'router',
      bundleName: 'com.example.shop',
      abilityName: 'EntryAbility',
      params: { page: 'logistics_detail' }
    })
  }

  // 用户点击拨打电话
  callCourier(): void {
    if (this.courierPhone) {
      postCardAction(this, {
        action: 'call',
        number: this.courierPhone
      })
    }
  }

  build() {
    Column() {
      // 顶部标题栏(可点击刷新)
      Row() {
        Text('物流跟踪')
          .fontSize(18)
          .fontColor(Color.White)
        Blank()
        Image($r('app.media.refresh'))
          .width(24)
          .height(24)
          .onClick(() => this.initData())
      }
      .margin({ top: 20, left: 16, right: 16 })

      // 进度圆环
      Stack() {
        Progress({ value: this.deliveryProgress, total: 100, type: ProgressType.Ring })
          .width(120)
          .height(120)
          .style({ strokeWidth: 10, backgroundColor: '#333', foregroundColor: '#0A59F7' })
        Column() {
          Text(`${this.deliveryProgress}%`)
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
          Text('已送达')
            .fontSize(12)
            .fontColor('#AAA')
        }
      }
      .margin({ top: 20 })

      // 物流状态和预计送达
      Text(this.logisticsInfo)
        .fontSize(16)
        .fontColor(Color.White)
        .margin({ top: 16 })
      Text(`预计${this.arrivalTime}送达`)
        .fontSize(14)
        .fontColor('#AAA')
        .margin({ top: 8 })

      // 新增:快递员联系方式
      if (this.courierPhone) {
        Row() {
          Text('快递员:')
            .fontSize(14)
            .fontColor('#AAA')
          Text(this.courierPhone)
            .fontSize(14)
            .fontColor('#0A59F7')
            .onClick(() => this.callCourier())
            .margin({ left: 8 })
        }
        .margin({ top: 12 })
      }

      // 底部操作区
      Row() {
        Button('查看详情')
          .fontSize(12)
          .height(32)
          .backgroundColor('#0A59F7')
          .onClick(() => this.navigateToDetail())
        Blank()
        Text('左滑查看更多')
          .fontSize(12)
          .fontColor('#666')
      }
      .margin({ top: 20, left: 16, right: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000')
    .borderRadius(16)
    .onClick(() => this.navigateToDetail())
  }
}

3.2 接入意图框架(Intent Framework):让卡片“懂你”

为了让卡片出现在“小艺建议”和“智慧搜索”中,我们需要定义意图(Intent)。意图描述了卡片能提供的服务能力。

entry/src/main/resources/base/profile/下创建intents.json



{
  "intents": [
    {
      "name": "view_logistics", // 意图名称
      "actions": ["ohos.want.action.viewLogistics"], // 动作
      "uris": ["logistics://com.example.shop/detail"], // URI
      "entities": ["entity.order", "entity.delivery"], // 实体类型
      "category": "launcher", // 分类
      "keywords": ["物流", "快递", "订单", "送货"], // 关键词
      "displayName": "$string:logistics_intent_name",
      "description": "$string:logistics_intent_desc",
      "icon": "$media:logistics_icon",
      "targetAbility": "EntryAbility", // 目标Ability
      "excludeFromHistory": false
    },
    {
      "name": "buy_again", // 再次购买意图
      "actions": ["ohos.want.action.buyAgain"],
      "entities": ["entity.product"],
      "category": "recommendation", // 推荐类,会出现在小艺建议中
      "keywords": ["复购", "再次购买", "买过的东西"],
      "displayName": "$string:buy_again_intent_name",
      "targetAbility": "EntryAbility"
    }
  ]
}

module.json5中注册意图:



{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ts",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:icon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "actions": [
              "ohos.want.action.viewLogistics",
              "ohos.want.action.buyAgain"
            ],
            "entities": [
              "entity.order",
              "entity.delivery",
              "entity.product"
            ],
            "uris": [
              {
                "scheme": "logistics",
                "host": "com.example.shop"
              }
            ]
          }
        ]
      }
    ],
    "intents": [
      {
        "name": "view_logistics",
        "srcEntry": "./resources/base/profile/intents.json"
      }
    ]
  }
}

3.3 场景感知:基于位置和时间的智能推荐

利用@kit.LocationKit@kit.SensorServiceKit,让卡片在特定场景下主动出现。

创建entry/src/main/ets/service/SmartRecommendService.ets



import { geoLocationManager } from '@kit.LocationKit'
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'
import { preferences } from '@kit.ArkData'

export class SmartRecommendService {
  private static readonly STORE_KEY = 'smart_recommend'
  private static readonly HOME_LOCATION = { latitude: 39.9, longitude: 116.3 } // 家庭位置
  private static readonly WORK_LOCATION = { latitude: 39.95, longitude: 116.35 } // 工作位置

  /**
   * 判断是否处于“下班场景”
   * 条件:工作日17:00-19:00,且位置靠近家庭或工作地点
   */
  static async isOffWorkScenario(): Promise<boolean> {
    try {
      // 1. 检查时间(工作日17:00-19:00)
      const now = new Date()
      const day = now.getDay() // 0=周日, 1=周一...
      const hour = now.getHours()
      const isWeekday = day >= 1 && day <= 5
      const isOffWorkTime = hour >= 17 && hour < 19
      if (!isWeekday || !isOffWorkTime) return false

      // 2. 检查位置权限
      const atManager = abilityAccessCtrl.createAtManager()
      const grantStatus = await atManager.checkAccessToken(
        globalThis.context.tokenID,
        Permissions.LOCATION
      )
      if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) return false

      // 3. 获取当前位置
      const location = await geoLocationManager.getCurrentLocation()
      
      // 4. 计算距离(简化版,实际应使用球面距离公式)
      const distanceToHome = this.calculateDistance(
        location.latitude, location.longitude,
        this.HOME_LOCATION.latitude, this.HOME_LOCATION.longitude
      )
      const distanceToWork = this.calculateDistance(
        location.latitude, location.longitude,
        this.WORK_LOCATION.latitude, this.WORK_LOCATION.longitude
      )

      // 5. 如果距离家或公司小于2公里,认为是下班场景
      return distanceToHome < 2000 || distanceToWork < 2000
    } catch (err) {
      console.error('场景判断失败:', err)
      return false
    }
  }

  /**
   * 保存用户偏好,用于个性化推荐
   */
  static async saveUserPreference(key: string, value: string): Promise<void> {
    const store = await preferences.getPreferences(globalThis.context, this.STORE_KEY)
    await store.put(key, value)
    await store.flush()
  }

  /**
   * 计算两点间距离(简化版)
   */
  private static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
    // 实际开发中应使用 Haversine 公式
    return Math.sqrt(Math.pow(lat1 - lat2, 2) + Math.pow(lon1 - lon2, 2)) * 111000 // 粗略换算为米
  }
}

在卡片的aboutToAppear中调用:



aboutToAppear(): void {
  this.initData()
  this.startAutoRefresh()
  
  // 判断是否是下班场景,如果是,则推荐“再次购买”卡片
  SmartRecommendService.isOffWorkScenario().then(isOffWork => {
    if (isOffWork) {
      // 通知系统,当前适合展示“再次购买”意图的卡片
      postCardAction(this, {
        action: 'update_intent',
        intentName: 'buy_again'
      })
    }
  })
}

四、踩坑记录(官方文档没写的生态细节)

  1. 卡片刷新策略:卡片的自动刷新(通过systemTimer)会消耗电量。系统对卡片的刷新频率有限制,过于频繁的刷新会被系统限制。建议使用事件驱动刷新(如通过DDO监听数据变化),而非定时刷新。

  2. 意图框架的“冷启动”:当用户点击小艺建议中的卡片时,如果App进程不存在,系统会冷启动App。此时,需要在EntryAbilityonCreate中解析want参数,根据意图跳转到正确的页面,否则会一直停留在首页。

  3. 卡片的“存活周期”:卡片不是永久存在的。当用户卸载App、清除缓存或系统资源紧张时,卡片会被销毁。开发者需要处理卡片的onDestroy生命周期,清理资源。

  4. 多设备适配:卡片会在手机、平板、手表等不同设备上展示。必须使用响应式布局(如GridRowGridCol),并根据设备类型(deviceType)加载不同的UI资源。

  5. 隐私与权限:场景感知需要位置、时间等信息,必须严格遵守隐私合规要求。在获取位置信息前,必须申请权限并解释用途。如果用户拒绝,应优雅降级,不使用场景感知功能。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐