前两篇里,SlideDrop 已经完成了两件很关键的事:第一篇跑通“手机素材 → 精准碰一碰 → 演示页插入”的第一条链路;第二篇又把系统触点转换成页面坐标,并通过区域映射解决了“到底碰到了哪里”。到了第三篇,问题开始从“位置准不准”转向“内容该不该放进去”:图片碰到标题区怎么办?文本碰到图片区怎么办?目标槽位已经有内容时,是替换、拒绝,还是继续叠加? 这一篇我就把素材类型和占位槽位正式拆开,让 SlideDrop 从“碰哪放哪”继续走到“什么素材,放到什么位置才合理”。

请添加图片描述

一、第二篇解决了“落在哪里”,第三篇开始解决“能不能落”

第二篇写完以后,我特意连续做了几轮测试。

我拿同一张山景图片,分别去碰标题区、图片区、图表区和备注区。坐标转换已经没有问题,区域命中也比较稳定,日志里能清楚看到:

systemPoint: x=842, y=356
pagePoint: x=421, y=178
hitRegion: image

如果只看第二篇的目标,这已经算完成了。

但一旦我把测试素材从“只有图片”扩展成图片、文本、截图、简单图表数据之后,一个更现实的问题马上出现:区域命中了,并不代表素材就应该被接受。

最典型的场景就是:

  • 一段标题文本碰到了图片区;
  • 一张风景照片碰到了标题区;
  • 一个图表数据对象碰到了备注区;
  • 已经放了一张主图的图片区,又来了一张新图。

如果应用只做“命中区域 → 直接插入”,那么所谓精准落位最后会变成另一种混乱:位置是准的,业务语义却不准。

所以第三篇我给自己定了一个新的判断:

触点负责告诉我“用户碰到了哪里”,规则层负责判断“这个东西能不能放到这里”。

这两件事一定要分开。

HarmonyOS 7 的精准分享能力能帮助应用识别目标窗口和触碰坐标,应用拿到这些信息以后,仍然需要结合自己的业务界面决定最终插入位置和处理规则。SlideDrop 这一篇做的,就是把这个“应用自己的判断”真正补起来。

二、先看第三篇后的项目目录:规则层要正式出现了

前两篇里,项目结构还比较轻。到了第三篇,我不想继续把所有判断都堆在 SlidePage.ets 里,所以专门加了一层 rules。

这一版我把目录整理成下面这样:

SlideDropDemo/
├── AppScope/
├── entry/
│   └── src/
│       └── main/
│           ├── ets/
│           │   ├── pages/
│           │   │   ├── Index.ets
│           │   │   └── SlidePage.ets
│           │   ├── components/
│           │   │   ├── MaterialCard.ets
│           │   │   ├── SlideSlot.ets
│           │   │   └── PlacementToast.ets
│           │   ├── model/
│           │   │   ├── MaterialModel.ets
│           │   │   ├── SlotModel.ets
│           │   │   └── PlacementResult.ets
│           │   ├── rules/
│           │   │   └── PlacementRuleEngine.ets
│           │   ├── utils/
│           │   │   ├── CoordinateMapper.ets
│           │   │   └── HitTestUtil.ets
│           │   └── common/
│           │       └── SlideDropController.ets
│           └── resources/
└── ohosTest/

这次最关键的新文件有三个:

  • MaterialModel.ets:描述“手机端到底传过来了什么”;
  • SlotModel.ets:描述“演示页上这个槽位能接什么”;
  • PlacementRuleEngine.ets:把素材和槽位放在一起判断,返回允许、替换、拒绝等结果。

我很想把这一层单独拎出来,因为它让整个 Demo 的职责第一次真正清楚起来:

CoordinateMapper     解决坐标转换
HitTestUtil          解决命中哪个区域
PlacementRuleEngine  解决素材能否进入这个槽位
SlidePage            只负责把结果展示出来

当这几层各自只回答一个问题时,第三篇就不再只是“继续加几个 if”,而是在给后面第四篇的状态冲突和异常恢复提前打基础。

请添加图片描述

三、素材不能再只是一个 uri,我先把 MaterialModel 补完整

第一篇只有图片时,素材对象很简单,很多时候有一个 uri 就够了。

到了第三篇,这种结构已经不够用。因为规则层如果连素材是什么都不知道,就没法判断它适不适合某个槽位。

所以我先定义一个比较轻的 MaterialItem:

文件位置: entry/src/main/ets/model/MaterialModel.ets
用途: 统一描述图片、文本、图表数据和截图等投递素材。

export enum MaterialType {
  IMAGE = 'image',
  TEXT = 'text',
  CHART = 'chart',
  SCREENSHOT = 'screenshot'
}

export interface MaterialItem {
  id: string
  type: MaterialType
  name: string
  uri?: string
  text?: string
  width?: number
  height?: number
  size?: number
}

这段模型没有做得很重,但已经足够让业务层区分几类核心素材。

比如手机端选择一张普通照片时:

const material: MaterialItem = {
  id: 'img-001',
  type: MaterialType.IMAGE,
  name: '山景主图.jpg',
  uri: 'file://media/Photo/IMG_001.jpg',
  width: 1920,
  height: 1080
}

如果是用户准备投递的一段标题文本:

const material: MaterialItem = {
  id: 'text-001',
  type: MaterialType.TEXT,
  name: '演示标题',
  text: '季度项目进度汇报'
}

这时候 SlideDrop 才真正知道:

我收到的不是“一个数据对象”,而是一张图、一段字,或者一份图表数据。

后面所有“能不能插入”的判断,都建立在这层明确的素材语义上。

四、占位槽位也要有自己的规则,不能只靠视觉上画个虚线框

素材有类型以后,下一步就是把页面区域从“几块矩形”升级成真正的 Slot。

第二篇里,标题区、图片区、图表区、备注区主要用于 hitTest。第三篇里,我会继续给它们补上业务属性。

文件位置: entry/src/main/ets/model/SlotModel.ets
用途: 描述槽位类型、允许的素材类型以及当前占用状态。

import { MaterialType } from './MaterialModel'

export enum SlotType {
  TITLE = 'title',
  IMAGE = 'image',
  CHART = 'chart',
  NOTE = 'note'
}

export interface SlideSlot {
  id: string
  type: SlotType
  label: string
  rect: Rect
  accept: MaterialType[]
  occupied: boolean
  materialId?: string
}

然后我给四个槽位写出第一版接收规则:

private slots: SlideSlot[] = [
  {
    id: 'slot-title',
    type: SlotType.TITLE,
    label: '标题区',
    rect: this.titleRect,
    accept: [MaterialType.TEXT],
    occupied: false
  },
  {
    id: 'slot-image',
    type: SlotType.IMAGE,
    label: '图片区',
    rect: this.imageRect,
    accept: [MaterialType.IMAGE, MaterialType.SCREENSHOT],
    occupied: false
  },
  {
    id: 'slot-chart',
    type: SlotType.CHART,
    label: '图表区',
    rect: this.chartRect,
    accept: [MaterialType.CHART, MaterialType.SCREENSHOT],
    occupied: false
  },
  {
    id: 'slot-note',
    type: SlotType.NOTE,
    label: '备注区',
    rect: this.noteRect,
    accept: [MaterialType.TEXT, MaterialType.SCREENSHOT],
    occupied: false
  }
]

这里有一个我很在意的点:同一种素材不一定只能去一个地方,同一个槽位也不一定只接一种素材。

比如截图就很特别。

一张数据看板截图,可以进图表区;一张说明截图,也可能进备注区;它本质上仍然是图像,但业务上又不完全等于普通照片。

所以我没有把规则写成死板的“一对一映射”,而是让 Slot 自己声明一个 accept 数组。

这样后面规则变化时,不用回到页面里改一大堆条件。

请添加图片描述

五、真正的核心来了:PlacementRuleEngine 只负责判断,不直接改 UI

第三篇最关键的一层,就是 PlacementRuleEngine。

我给它定的职责非常克制:

输入一个素材和一个槽位,返回“允许插入、需要替换、还是拒绝”。

它不直接改页面,也不直接弹 Toast,更不直接操作图片控件。

先定义结果类型:

export enum PlacementAction {
  INSERT = 'insert',
  REPLACE = 'replace',
  REJECT = 'reject'
}

export interface PlacementResult {
  action: PlacementAction
  message: string
}

然后写第一版规则:

文件位置: entry/src/main/ets/rules/PlacementRuleEngine.ets
用途: 判断素材类型与槽位规则是否匹配,以及槽位已占用时如何处理。

export class PlacementRuleEngine {
  static evaluate(
    material: MaterialItem,
    slot: SlideSlot
  ): PlacementResult {
    const accepted = slot.accept.includes(material.type)

    if (!accepted) {
      return {
        action: PlacementAction.REJECT,
        message: `${material.type} 不能放入 ${slot.label}`
      }
    }

    if (slot.occupied) {
      return {
        action: PlacementAction.REPLACE,
        message: `${slot.label} 已有内容,准备替换`
      }
    }

    return {
      action: PlacementAction.INSERT,
      message: `允许插入到 ${slot.label}`
    }
  }
}

这层代码看起来并不复杂,但它带来的变化很明显。

以前是:

触点 → 命中图片区 → 插图

现在变成:

触点
  ↓
命中图片区
  ↓
拿到 MaterialItem
  ↓
RuleEngine 判断是否接受
  ↓
INSERT / REPLACE / REJECT
  ↓
页面执行对应动作

这一步让“精准”不再只体现在坐标上,也开始体现在业务规则上。

六、页面层现在只需要串起 3 个判断:坐标、槽位、规则

有了前面几层之后,SlidePage.ets 反而变简单了。

它不需要知道所有规则细节,只要负责把事件串起来。

文件位置: entry/src/main/ets/pages/SlidePage.ets
用途: 把触点坐标、槽位命中和规则判断连接成完整投递流程。

private async handlePreciseDrop(
  point: Point,
  material: MaterialItem
) {
  const pagePoint = this.coordinateMapper.toPagePoint(point)
  const slot = this.hitTestUtil.findSlot(pagePoint, this.slots)

  if (!slot) {
    this.statusText = '没有命中可投放区域'
    return
  }

  const result = PlacementRuleEngine.evaluate(material, slot)

  console.info(
    `[SlideDrop] material=${material.type}, slot=${slot.type}, action=${result.action}`
  )

  switch (result.action) {
    case PlacementAction.INSERT:
      await this.insertToSlot(slot, material)
      break
    case PlacementAction.REPLACE:
      await this.replaceSlotContent(slot, material)
      break
    case PlacementAction.REJECT:
      this.statusText = result.message
      break
  }
}

这里我特别喜欢的一点,是现在每一层失败都能说清楚原因:

  • slot 没找到:说明坐标没命中;
  • REJECT:说明类型不匹配;
  • REPLACE:说明槽位已经有内容;
  • INSERT:说明可以正常插入。

这种结构比一堆嵌套 if/else 更适合连载,因为后面第四篇要继续补冲突和状态恢复时,基本不用推翻第三篇。

七、图片进入图片区后,也不能直接“原样塞进去”

做到这里,我又遇到一个非常现实的问题:

图片类型虽然匹配了图片区,但图片比例并不一定匹配占位槽。

比如一张 9:16 的竖图投到一个 16:9 的图片区,如果直接拉伸,会很难看;如果强制铺满,又可能把主体裁掉。

所以第三篇里,我顺手补了一个很轻的适配策略。

我没有做复杂编辑器,只先支持三个模式:

export enum FitMode {
  CONTAIN = 'contain',
  COVER = 'cover',
  ORIGINAL = 'original'
}

第一版默认规则是:

  • 普通演示主图使用 COVER;
  • 截图或信息图优先 CONTAIN;
  • 特殊情况下保留 ORIGINAL。

简单处理可以写成:

private resolveFitMode(material: MaterialItem): FitMode {
  if (material.type === MaterialType.SCREENSHOT) {
    return FitMode.CONTAIN
  }

  if (material.type === MaterialType.IMAGE) {
    return FitMode.COVER
  }

  return FitMode.ORIGINAL
}

然后插入时把这个模式一起交给 SlideSlot:

private async insertToSlot(
  slot: SlideSlot,
  material: MaterialItem
) {
  const fitMode = this.resolveFitMode(material)

  slot.occupied = true
  slot.materialId = material.id

  this.currentPlacement = {
    slotId: slot.id,
    material,
    fitMode
  }

  this.statusText = `${material.name} 已插入 ${slot.label}`
}

这个处理虽然很轻,但它让第三篇的“智能插入规则”更像回事了。

因为“能不能放进去”和“放进去以后怎么展示”,本来就是两个连续的问题。

请添加图片描述

八、槽位已占用时,我没有直接替换,而是把冲突暴露出来

第三篇还有一个我不想绕过去的问题:目标区域已经有内容怎么办?

如果是一个真正的演示编辑器,直接覆盖用户现有内容通常并不友好。可如果每次都拒绝,又会让精准投递变得很笨。

所以第一版我先采取一个中间方案:

规则层返回 REPLACE,但页面层先展示“已有内容”的状态,再由用户确认。

也就是说,RuleEngine 只负责发现冲突,不替用户做最终决定。

页面可以显示:

图片区已有“产品主图.jpg”
新素材“现场照片.jpg”准备替换
[取消] [替换]

确认后再真正执行:

private async confirmReplace(
  slot: SlideSlot,
  material: MaterialItem
) {
  const previousId = slot.materialId

  await this.replaceSlotContent(slot, material)

  console.info(
    `[SlideDrop] replace slot=${slot.id}, old=${previousId}, new=${material.id}`
  )
}

这段逻辑为第四篇留下了很自然的入口。

因为第四篇原本就要处理:连续投递、重复素材、目标区域已占用、失败、取消以及页面切换。

第三篇在这里不需要把全部冲突状态一次解决,只要把“冲突”从隐藏行为变成显式状态,就已经完成了它的任务。

请添加图片描述

九、为了不让规则越来越乱,我又加了一层“素材归一化”

第三篇写到这里,我又发现一个很容易被忽略的问题:手机端传过来的素材描述,未必永远长成我们想要的样子。

比如同样是一张图片,有的来源只有 uri,有的会带宽高,有的可能还会带 MIME 类型;截图和普通照片从文件角度看都可能是 image,但业务上我又希望把它们区分开。

如果这些差异全部交给 PlacementRuleEngine 去判断,规则层会很快变脏。它本来只应该回答“能不能放”,最后却要开始关心“这个字段有没有”“这个来源是不是截图”“宽高是不是缺失”。

所以我补了一个非常轻量的归一化步骤,把输入先整理成统一的 MaterialItem。

文件位置: entry/src/main/ets/common/MaterialNormalizer.ets
用途: 把不同来源的素材数据整理成统一业务模型,再交给规则层。

export interface RawDropData {
  uri?: string
  text?: string
  mimeType?: string
  width?: number
  height?: number
  source?: string
}

export class MaterialNormalizer {
  static normalize(data: RawDropData): MaterialItem {
    const type = this.resolveType(data)

    return {
      id: `${Date.now()}`,
      type,
      name: this.resolveName(data, type),
      uri: data.uri,
      text: data.text,
      width: data.width,
      height: data.height
    }
  }

  private static resolveType(data: RawDropData): MaterialType {
    if (data.source === 'screenshot') {
      return MaterialType.SCREENSHOT
    }

    if (data.mimeType?.startsWith('image/')) {
      return MaterialType.IMAGE
    }

    if (data.mimeType === 'application/chart+json') {
      return MaterialType.CHART
    }

    return MaterialType.TEXT
  }
}

这样做以后,后面的主链路就更稳定了:

原始投递数据
  ↓
MaterialNormalizer
  ↓
MaterialItem
  ↓
PlacementRuleEngine
  ↓
插入 / 替换 / 拒绝

这一步的价值不在于多了一层文件,而在于它把“输入数据差异”和“业务规则差异”分开了。后面第四篇如果要处理失败重试或重复素材,也不至于把各种来源判断继续往页面和规则层里塞。

十、规则命中后,页面反馈也要说人话,不能只打印 REJECT

还有一个很容易被技术 Demo 忽略的细节:规则层判断正确,不代表用户体验就正确。

比如文本碰到了图片区,代码里返回:

action=REJECT

对开发者来说已经很清楚,但用户并不知道为什么失败。

所以第三篇里我给页面状态补了一层更明确的反馈。

private applyPlacementResult(result: PlacementResult) {
  switch (result.action) {
    case PlacementAction.INSERT:
      this.statusText = '素材已插入当前槽位'
      break
    case PlacementAction.REPLACE:
      this.statusText = '当前槽位已有内容,请确认是否替换'
      break
    case PlacementAction.REJECT:
      this.statusText = result.message
      break
  }
}

我更喜欢这种做法,因为它让“规则”不仅存在于代码里,也能在 UI 上被理解。

尤其是第三篇这种主题,如果读者只看到日志里一堆 INSERT / REPLACE / REJECT,很容易把文章看成规则引擎练习;但如果手机页面上能同时看到“图片区只接受图片或截图”“当前槽位已有内容”等提示,它就会更像一个真实的编辑器。

这里我还加了一个很小的视觉状态:

  • 可接受素材时,槽位边框保持低饱和绿色;
  • 不匹配时,短暂出现浅红边框和提示;
  • 需要替换时,槽位出现橙色提示状态;
  • 真正插入成功后,再恢复普通状态。

这不是为了做花哨动画,而是为了让“规则发生了什么”一眼可见。

十一、规则层越清楚,第四篇的异常和状态才越好接

做到这一篇后,我越来越确定:第三篇真正的价值不是多支持了几种素材,而是把“位置判断”和“业务规则”拆开了。

这件事对下一篇非常重要。

因为第四篇要处理的连续投递、重复素材、用户取消、失败恢复,本质上都依赖一个前提:系统必须先知道当前这次投递到底属于哪一种业务状态。

如果第三篇没有 MaterialType、SlotType、PlacementAction 这些明确状态,第四篇就只能继续堆 if。

现在则不一样。后面可以很自然地把状态扩成:

IDLE
PREPARING
WAITING_DROP
VALIDATING
INSERTING
WAITING_REPLACE_CONFIRM
SUCCESS
FAILED
CANCELED

也就是说,第三篇其实是在给第四篇的状态机做铺垫。

这就是我喜欢这种 Demo 连载的地方:每一篇不只是完成当下的功能,还会给下一篇留出结构空间。

十二、我还专门测了“同一素材跨不同槽位”的边界

为了确认规则层不是只在理想路径下有效,我又拿同一张素材连续碰不同槽位。

比如一张产品主图:

  • 碰图片区,应当 INSERT;
  • 碰标题区,应当 REJECT;
  • 碰图表区,如果它只是普通照片,也应该 REJECT;
  • 碰备注区,如果业务不允许图片备注,同样拒绝。

这类测试很重要,因为它能防止我们把“某一条成功路径”误认为“规则设计已经正确”。

我最后给每次判断都保留一条统一日志:

[Rule] material=image -> slot=image : INSERT
[Rule] material=image -> slot=title : REJECT
[Rule] material=image -> slot=chart : REJECT

这样一来,调试时我不用盯着页面猜,而是能直接确认规则是否按预期执行。

更关键的是,这组测试把第三篇的目标真正闭环了:我们不只是证明“某种素材能进入某个槽位”,还证明了“不合适的素材会被明确挡住”。这才是智能插入规则应该具备的最基本边界。

十三、我给第三篇补了一组真实测试:不同素材碰不同槽位

规则写出来以后,如果只跑一条“图片 → 图片区”的成功路径,第三篇其实还不算站住。

所以我给自己准备了几组很直接的测试:

1)图片 → 图片区

预期:INSERT

material=image
slot=image
result=INSERT

2)文本 → 标题区

预期:INSERT

material=text
slot=title
result=INSERT

3)文本 → 图片区

预期:REJECT

material=text
slot=image
result=REJECT

4)图表 → 图表区

预期:INSERT

material=chart
slot=chart
result=INSERT

5)截图 → 图表区

预期:允许,并使用 CONTAIN

6)新图片 → 已占用图片区

预期:REPLACE,但先等待用户确认。

这几组测试特别适合在 DevEco 的日志里看,因为它能把“坐标没问题但规则不允许”和“坐标命中且规则允许”明确区分开。

我最后希望日志至少能达到这种粒度:

[Drop] point=(421,178) hit=image
[Rule] material=image slot=image action=INSERT
[Insert] success fit=COVER

[Drop] point=(438,190) hit=image
[Rule] material=text slot=image action=REJECT

[Drop] point=(419,176) hit=image
[Rule] material=image slot=image action=REPLACE

做到这里以后,我再回头看第一篇,会发现 SlideDrop 的主线已经变化很明显了。

第一篇更像“把东西送过去”;
第二篇是“把坐标找准”;
第三篇则开始变成“知道什么东西该落在哪里,而且落下以后怎么处理”。

十四、类型二综合图在这一篇最适合讲什么

你前面让我保留一种“类型二”图:里面同时有拟真 DevEco、手机截图、关系图、实机照片,再配合少量红色标注。

第三篇我觉得它最适合讲“素材类型 → 槽位规则 → 最终落位”这条线。

关系图部分可以很简单:

手机端素材
  ↓
MaterialType
  ↓
精准碰一碰 + 坐标命中
  ↓
SlotType
  ↓
PlacementRuleEngine
  ↓
INSERT / REPLACE / REJECT

旁边再用 DevEco 截图展示 PlacementRuleEngine.evaluate(),用纯手机截图展示当前槽位和结果,用实机照片展示最终插入。

这种组合图有一个很明显的好处:

它不是为了“好看”,而是能让读者在一张图里把代码、关系和效果串起来。

所以后面几篇我也会继续保留,但每一篇的内容重心会变,顺序也可以调整,不让五篇看起来完全是同一模板。

十五、第三篇我会怎么验收

这篇的验收标准已经不能只是“素材插进去了”。

我会重点看下面几个问题。

1)素材类型有没有被明确识别

图片、文本、截图、图表不能再都当成一个 uri 处理。

2)槽位有没有声明自己的接收规则

标题区、图片区、图表区、备注区必须知道自己能接什么。

3)规则不匹配时有没有明确拒绝

比如文本碰到图片区,不能静默塞进去,也不能假装成功。

4)槽位占用后有没有进入冲突流程

已有主图再来一张图,应该进入 REPLACE,而不是直接覆盖。

5)图片展示有没有基本适配

普通照片和截图至少要能区分 COVER 与 CONTAIN,否则“插入成功”仍然可能带来很差的视觉结果。

6)日志能否区分坐标问题和规则问题

这是我很看重的一点。

如果没有命中槽位,是坐标层的问题;如果命中了但被拒绝,是规则层的问题。日志必须能把这两种情况分开,否则后面排查会非常痛苦。

请添加图片描述

十六、本文小记

第三篇写完以后,我对 SlideDrop 这个 Demo 的感觉明显变了。

第一篇的时候,它还是一个“手机素材能不能送到演示文稿里”的实验;
第二篇把触点和区域映射做稳以后,它开始具备“碰哪放哪”的样子;
第三篇再加上素材类型和槽位规则以后,它才真正开始有一点“编辑器逻辑”。

这一篇最重要的变化,我觉得不是多了几个枚举,也不是多了一个 PlacementRuleEngine,而是我们把两个经常混在一起的问题拆开了:

触点决定位置,规则决定合理性。

系统能力帮助我们得到目标窗口和触碰坐标,SlideDrop 自己则要决定:

  • 这个素材是什么;
  • 这个槽位允许什么;
  • 能不能插;
  • 已占用时要不要替换;
  • 插进去以后该用什么展示策略。

当这些问题有了明确边界以后,SlideDrop 就不再只是“碰一碰传文件”的外壳,而更像一个围绕精准投递长出来的演示文稿协作 Demo。

下一篇我会继续处理第三篇已经暴露出来但还没有彻底解决的东西:

连续投递、重复素材、目标槽位冲突、用户取消、页面切换以及失败后的状态恢复。

那一篇会更偏状态和异常,也会更接近真实项目里真正难维护的地方。

另外我会刻意保留两类“失败是正常结果”的情况:没有命中任何槽位,以及素材类型和槽位不匹配。前者说明触点或布局映射需要继续检查,后者则说明规则层正在正确工作。很多 Demo 容易把所有失败都写成同一句“投递失败”,这样一来坐标问题、规则问题和真正的系统异常会混在一起。第三篇把这三类状态分开以后,后面做连续投递和异常恢复时,诊断成本会低很多。

Logo

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

更多推荐