HarmonyOS 7 精准碰一碰 SlideDrop 实战 03:素材类型 + 占位槽位完善演示页智能插入规则【鸿蒙心迹】
前两篇里,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 容易把所有失败都写成同一句“投递失败”,这样一来坐标问题、规则问题和真正的系统异常会混在一起。第三篇把这三类状态分开以后,后面做连续投递和异常恢复时,诊断成本会低很多。
更多推荐



所有评论(0)