原生鸿蒙像素画板实战 12:填充与取色 像素画里的填充和取色经常被低估。填充按钮看上去只是“把同色区域换成新色”取色按钮也只是“读一个格子的颜色”但它们都直接碰到编辑器最核心的数据透明色怎么处理、半透明颜色怎么保留、取色应该读活动层还是合成结果、一次填充怎样不把历史记录拆成几百个小操作。bitArt 让像素服务返回下一份像素和可选的 pickedColor再由图层与工作流决定读合成色、写活动层以及更新编辑状态。填充前先分清目标颜色与透明色如果把填充写成递归函数小画布也许看不出问题尺寸升到 128×128 或 256×256 后就可能遇到调用栈过深。另一类问题是用当前颜色判断是否继续扩散却忘了比较起始格颜色导致同色区域没填完或把本不相连的区域改掉。取色则容易犯“顺手切到画笔”的错用户本来可能只想确认颜色不应该被强制改变当前工具。谁负责读取颜色谁负责写入颜色填充和取色虽然来自同一次点按却分别属于写操作和读操作。把它们在服务返回值里拆开可以让“吸到颜色”不意外制造一条历史记录。取色不应该偷偷改动画笔状态正常编辑路径先在LayerCanvasService处理 picker它读取所有可见图层合成后的颜色不受活动图层锁定状态影响也不修改任何图层像素。完全透明的位置会保留内部透明标记其他颜色按合成结果作为 pickedColor 返回。fill、pencil 与 eraser 再进入活动层写入路径具体像素计算由PixelCanvasService完成当填充透明度为 0 时它直接返回原数组不让一次无效点击进入后续链路。真正填充时会把 activeColor 按透明度转成内部颜色格式然后从起始索引向四个相邻方向扩散。这种安排让图层服务可以复用单层逻辑。它只需找到活动层把工具结果放回对应图层而工作流只在 pickedColor 非空时更新活动色。取色因此是“读状态”填充才是“改状态”两者的副作用不会混在一起。取色在图层服务中直接读取合成色填充才会继续检查活动层锁定状态并写入单层像素。if (toolId picker) { return { layersPixels: layersPixels, pickedColor: LayerCanvasService.getCompositePixel(layersPixels, layers, width, row, col) }; } const layerIndex LayerCanvasService.getLayerIndex(layers, activeLayerId); if (layers[layerIndex].locked) { return { layersPixels: layersPixels, pickedColor: }; }广度优先遍历更适合大面积同色区域填充使用队列和 visited 数组。队列中的索引先被标记出队时再次确认它仍是目标颜色再写入新颜色并尝试上下左右四个邻居。这样不会在大块同色区域里反复访问同一个像素也避免递归深度随着区域面积增长。它目前是四邻域填充斜对角相同颜色不会因为只接触角落而连在一起这更符合大多数像素画工具的直觉。const nextPixels pixels.slice(); const visited: boolean[] new Arrayboolean(pixels.length); const queue: number[] [startIndex]; visited[startIndex] true; while (queueIndex queue.length) { const index queue[queueIndex]; if (pixels[index] ! targetColor) continue; nextPixels[index] color; const row Math.floor(index / width); const col index % width; // 仅在行列边界允许时将上下左右同色邻居加入队列 }读色与改色走两条不同的状态路径工作流拿到 LayerToolResult 后会根据当前工具计算是否需要脏区。填充通常会影响不规则的大区域不应该假装它只有一个小矩形渲染层可以据此选择完整刷新。取色没有像素变化只更新活动颜色也不应创建撤销快照。活动图层锁定时由图层服务拒绝写入读色路径仍可保持为只读。填充会改写活动层而取色只更新活动色这两个结果都应由工作流统一收束避免组件各自写状态。const result LayerCanvasService.applyTool(target.layersPixels, target.editorLayers, target.activeLayer, target.canvasWidth, target.canvasHeight, row, col, target.activeTool, target.activeColor, EditorCanvasDrawingWorkflowService.getActivePaintSize(target), EditorCanvasDrawingWorkflowService.getActiveToolOpacity(target)); EditorCanvasStateService.setLayersPixels(target, EditorCanvasDrawingWorkflowService.maskLayersPixelsToSelection( target, target.layersPixels, result.layersPixels), dirtyBounds); EditorCanvasDrawingWorkflowService.applyPickedColor(target, result.pickedColor, hooks);填充和取色中最容易误判的地方开始填充前必须判断 targetColor 与新颜色是否相同否则会做一轮没有意义的遍历。队列索引必须根据 width 还原 row 和 col左右邻居不能跨到上一行或下一行。取色读取的是可见图层的合成结果不要误写成只读取活动层完全透明时仍要按内部透明色语义处理。填充性能和视觉正确性要同时考虑。小面积区域可以局部更新大面积或边界复杂的区域应当让渲染层走更稳妥的完整路径。把算法限制在活动图层也保证了填充不会穿透隐藏层或半透明合成结果用户编辑的是层数据不是最终显示的合成图。为填充范围建立可复现样本填充功能适合准备一组很小但结构清晰的像素样本封闭色块、两个只在对角接触的色块、被透明格切开的区域、同一颜色分布在多个图层的场景。用这组样本先确定四邻域规则再观察填充后活动层以外的图层是否保持不变。若直接用一张复杂作品测试出现错误时很难判断是连通算法、图层路由还是渲染缓存的问题。透明度也应单列测试。填充 100% 的不透明色、50% 的半透明色和 0% 的无效填充结果的差别不仅是画面颜色还包括是否产生历史、是否需要保存。取色则要从不透明格、半透明格和透明格分别读取确认返回值没有把内部透明标记泄漏到用户可选颜色中。把这些样本保留下来之后优化渲染时也能防止改色逻辑被误伤。填充后的渲染选择也要保守填充区域的形状通常不是规则矩形。一个 64×64 画布里用户可能从细缝进入一大片背景最终影响数千个像素。此时工作流不应该把起点附近的小矩形当成脏区并强迫渲染器局部清除因为旧颜色可能分散在画布各处。更稳妥的做法是让填充结果按实际刷新策略走完整绘制局部优化留给能准确描述影响范围的画笔和直线。半透明填充还有一个语义需要提前说清它把当前颜色按填充透明度转换为内部颜色再替换连通区域的原色它不是在每一个目标格上做图层级的混合。图层的 source-over 合成仍发生在之后的图层合成阶段。把这两层概念分开用户调 50% 填充时才不会误以为它会自动保留目标像素的全部颜色信息。怎样确认改色范围没有跑偏填充检查要把连通性、透明度和锁层三个维度拆开避免只在最简单的色块上验证。在封闭区域、多个不相连同色区域和带透明洞的区域分别填充确认只有四邻域连通部分被修改。把填充颜色设成起始格同色、透明度设为 0确认像素、历史与保存状态都不改变。改变图层可见性后在同一格取色确认返回的是新的合成色但当前工具不被强制切换。锁定活动层后填充确认没有改色随后解锁重复操作确认一次填充只产生一个撤销步骤。填充与取色检查记录 封闭色块只改起点所在的连通区域 取色读取可见图层合成色且不修改像素 同色填充和 0% 透明度不创建历史 锁定层填充无效取色保持只读填充与取色的能力边界当前填充以精确颜色字符串作为连通条件不包含容差填充、按合成后的可见色填充或跨图层填充。对像素画的离散色块而言这是更可预测的默认行为若引入容差需要额外定义 RGBA 距离和半透明比较规则。这一篇的重点是把“读取合成色”和“大面积修改活动层”放进两条副作用清楚的数据路径。接下来把视线移到选区怎样让一块像素可以移动、清除并且不在拖动中留下拖影。