十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

鸿蒙自定义组件RcSwitch重构:颜色语义化与状态机设计实战

鸿蒙自定义组件RcSwitch重构:颜色语义化与状态机设计实战 作为一个长期在鸿蒙生态里折腾自定义组件的开发者我拿到 “RcSwitch” 这个标题时是有共鸣的。一个看似简单的开关组件居然能花掉半年时间这在不懂行的人看来可能有点夸张但真正做过自研组件库的人会明白难的不是把滑块从左边挪到右边而是把“颜色系统”和“状态联动”这两件表面不起眼、实际上牵一发动全身的事做到位。这个组件在 HarmonyOS 6 的 ArkTS 环境下重新设计后主要解决了两类问题一是组件配色彻底摆脱了“写死颜色”的硬编码模式二是把禁用disabled和加载loading这两个状态从“互不相干”变成了“有序联动”。如果你正在做鸿蒙应用的自定义组件或者业务里频繁需要带异步反馈的开关按钮这篇文章应该能帮你省掉不少凭空踩坑的时间。我会从设计动机、颜色系统拆解、状态机设计、核心代码实现、真机踩坑这几个维度把 RcSwitch 这半年的沉淀一次讲透。1. 起因为什么一个开关组件值得花半年重做先交代一下背景。RcSwitch 最初并不是从零新建的组件而是在原有开关基础上做的第二次重构。第一版上线后业务方用得倒是挺顺但随着接入页面增多问题开始集中暴露。1.1 第一版组件的三个硬伤第一版 RcSwitch 的实现方式非常直接背景色用Color.Gray和Color.Blue滑块用Color.White切换动画用animateTo包一层。表面看没什么问题但实际接入十几个业务页面后三个硬伤开始显现。第一个硬伤是颜色散落。业务方希望开关能适配不同页面的品牌色比如有的页面主题色是蓝色有的是绿色还有的是品牌自定义色。第一版把颜色全部写死在组件内部导致业务方只能通过新增属性去覆盖比如加一个activeColor、inactiveColor后来又加thumbColor、loadingColor……半年不到组件属性膨胀得厉害颜色逻辑也乱成一锅粥。第二个硬伤是深浅色模式适配靠人肉补丁。鸿蒙应用的深色模式切换是系统级的第一版组件在浅色模式下看着正常一旦切到深色模式灰色部分会变得浑浊滑块和背景的对比度明显不足。业务方只能被迫在每个页面里手动判断是否深色模式然后传不同的颜色值进来这完全违背了组件封装的初衷。第三个硬伤是状态管理太粗糙。第一版只有一个disabled属性加载状态根本没有。但实际业务中有大量异步开关场景比如控制智能设备、切换消息免打扰、开启云同步这些操作都需要等接口返回才能确定最终状态。没有加载态用户快速连续点击就会导致请求重复发送体验非常糟糕。1.2 重做时确定的设计目标基于上面的问题我在重做前给自己定了几个明确目标这半年所有迭代都是围绕这几条展开的。第一颜色必须语义化。组件内部不允许出现具体颜色值所有颜色都通过配置类或资源文件注入业务方只需要传一个主题色组件自动推导出配套的背景色、前景色、加载色。第二状态必须独立且有序。disabled 和 loading 不能是互不相关的两个布尔值而应该是一个统一状态机里的不同节点。组件内部要明确知道“当前是禁用还是加载中”“加载中能不能被禁用打断”“禁用时加载动画还要不要转”这些边界问题。第三动画必须可中断、可恢复。开关切换过程中的动画不能因为状态突变而卡死加载态转圈和滑块位移不能互相打架。这三个目标听起来不复杂但真正落地时牵扯到的细节非常多。下面我从颜色系统和状态系统两个角度分别展开。2. 颜色系统设计从硬编码到语义化令牌颜色系统是 RcSwitch 重构中改动最大、也是收益最明显的一部分。这部分的核心理念是“分层”把颜色从“具体值”变成“语义引用”。2.1 颜色分层的三级结构我最终把颜色分为三层基础色板、语义色、组件属性色。基础色板是最底层的颜色 token比如品牌主色、成功色、警告色、危险色、中性灰阶。这些颜色通常由设计侧统一维护在整个设计系统内共享。以 RcSwitch 为例基础色板大致长这样// theme/color-token.ets export class ColorToken { static readonly brandPrimary: ResourceColor $r(app.color.brand_primary); static readonly brandPrimaryDisabled: ResourceColor $r(app.color.brand_primary_disabled); static readonly neutralGray: ResourceColor $r(app.color.neutral_gray); static readonly neutralGrayLight: ResourceColor $r(app.color.neutral_gray_light); static readonly textInverse: ResourceColor $r(app.color.text_inverse); }语义色是第二层它以基础色板为原料表达“这个颜色是干嘛用的”。比如“开关激活态背景色”“开关非激活态背景色”“滑块颜色”“加载指示器颜色”。语义色不关心具体色值只关心业务语义。组件属性色是第三层它是 RcSwitch 内部渲染时真正引用的颜色。这层的作用是把前两层的抽象和组件实现解耦即使将来基础色板完全换一套组件代码也不需要改。这里有一个很关键的设计决策基础色板优先使用资源文件Resource而不是直接写 Color 常量。原因是 HarmonyOS 的资源限定符天然支持深色模式切换$r(app.color.xxx)在深浅色模式下会自动解析为不同值。如果直接在代码里写Color.Blue那深色模式就得人工判断再替换又回到了第一版的老路。2.2 深浅色模式的自动适配实现有了资源文件以后深浅色适配就变得非常省心。我在 resource 目录下维护了两份颜色配置一份在base/element/color.json一份在dark/element/color.json。以开关的未激活轨道色为例浅色模式下用浅灰深色模式下用深灰// base/element/color.json { color: [ { name: switch_track_off, value: #E5E5E9 } ] }// dark/element/color.json { color: [ { name: switch_track_off, value: #3A3A3E } ] }组件内部引用时不需要关心当前是深色还是浅色直接写$r(app.color.switch_track_off)即可。系统在页面渲染时会根据当前的系统深浅色模式自动解析出正确值。但这里有一个容易被忽略的坑组件库如果作为独立 HAR 包或 HSP 包发布资源文件的引用路径要特别注意。早期我直接把资源放在组件库模块内结果 App 工程引用后解析不到颜色排查了很久才发现是资源目录没合并对。后来统一把资源放到组件库的src/main/resources下通过$r引用时才稳定。另外如果业务方需要支持“应用内手动切换深色模式”比如 App 内置夜间模式开关而不跟随系统仅靠 Resource 就不够了。这时需要配合媒体查询mediaquery监听颜色模式变化或者在配置类里手动注入一套深浅色值。我在 RcSwitch 里提供了theme配置入口允许外部传入一套完整的SwitchTheme对象优先级高于内置资源。2.3 自定义主题色的推导与对比度控制支持自定义主题色是 RcSwitch 的一个核心卖点。业务方只需要传入一个品牌色组件需要自动推导出与之配套的激活轨道色、滑块颜色、加载颜色。推导逻辑并不复杂核心是对品牌色做亮度调整。以品牌色brandColor为例激活轨道色品牌色本身或略微降低亮度激活轨道禁用色品牌色加灰、降透明度滑块颜色根据品牌色亮度自动判断白色或深灰色保证对比度其中“滑块颜色”的选取有一个相对实用的公式。先计算品牌色的相对亮度参考 WCAG 的相对亮度公式然后判断与白色对比度高还是与黑色对比度高选对比度更高的那个function getContrastTextColor(bgColor: string): string { // 将 #RRGGBB 转为 RGB 分量 const r parseInt(bgColor.substr(1, 2), 16) / 255; const g parseInt(bgColor.substr(3, 2), 16) / 255; const b parseInt(bgColor.substr(5, 2), 16) / 255; // 计算相对亮度线性化处理 const linearize (c: number) c 0.03928 ? c / 12.92 : Math.pow((c 0.055) / 1.055, 2.4); const L 0.2126 * linearize(r) 0.7152 * linearize(g) 0.0722 * linearize(b); // 与白色和黑色分别计算对比度取值大者 const contrastWithWhite 1.05 / (L 0.05); const contrastWithBlack (L 0.05) / 0.05; return contrastWithWhite contrastWithBlack ? #FFFFFF : #1A1A1A; }这个“自动判断前景色”的逻辑在深色模式下尤其重要。如果品牌色本身是深色滑块再用深色就会糊成一片如果品牌色很亮滑块用白色又会看不清楚。公式算一遍比设计师逐个场景手动调要可靠得多。2.4 禁用态颜色不能只靠透明度禁用态的颜色处理是我踩过比较深的坑。第一版组件做禁用态时直接在正常颜色上叠了 0.4 的透明度看起来“变淡了”就觉得大功告成。但实际上透明的灰色叠在深色背景上会出现一种混浊感尤其在深色模式下原本就偏深的轨道色再叠透明度几乎和背景融为一体。后来我换了一个思路禁用态使用专门的禁用色而不是在正常色上叠加透明度。也就是说为每一个语义色都预先定义好对应的 disabled 版本。这样虽然颜色定义的工作量变多了但视觉可控性大幅提升。比如轨道激活色的禁用版不是品牌色的半透明而是在品牌色基础上降饱和、降对比度的实体色。这个转换不一定要在代码里实时计算我建议直接在资源文件里配置好因为禁用态的视觉效果属于设计决策设计侧定了值开发侧直接用就好不要试图用公式去猜设计师的意图。3. 禁用与加载状态比想象中复杂的两个布尔值很多人觉得禁用和加载不就是一个disabled加一个loading嘛这有什么好讲的但真正把交互细节盘清楚后你会发现这两个值之间的关系远不是“非此即彼”那么简单。3.1 禁用态的三个维度禁用态至少要兼顾三个维度交互、视觉、语义。交互维度上禁用后不能响应点击、不能触发动画、不能产生回调。这是最基本的。但“不能响应点击”也要分清楚是“完全无反应”还是“有震动反馈但无动作”我建议在组件设计时把这两种模式都暴露出来比如disabledFeedback属性默认无反应需要时由业务方决定是否给触感反馈。视觉维度上禁用态是 RcSwitch 颜色系统里比较典型的应用场景。轨道背景、滑块、loading 指示器都要切换到对应的 disabled 色。注意这里不是所有元素都要变灰比如滑块在禁用态保持白色或浅灰色轨道变成低饱和色两者之间依然要保持最基本的可辨识度。语义维度上禁用态应该让用户“一看就懂”。如果用户不知道为什么这个开关不能点那禁用态在体验上就是失败的。我在 RcSwitch 里保留了disabledReason属性用于在辅助功能场景下播报具体原因。比如“当前没有权限修改免打扰设置”而不是干巴巴地读“不可用”。3.2 加载态的交互锁与超时保护加载态的核心目的是在异步操作完成前锁住用户重复交互。RcSwitch 在加载态下会做三件事。第一拦截点击。加载中点击组件直接忽略不触发任何回调。这个逻辑要在onClick的最前面判断越早拦截越好。第二视觉反馈。加载指示器在滑块上显示告诉用户“你的操作正在执行”。RcSwitch 的加载指示器是一个顺时针转动的圆弧直径比滑块略小位置覆盖在滑块中央。第三超时保护。接口如果一直没有返回组件不能永远转圈。我默认设置了 5 秒超时超时后自动退出加载态并回调一个onTimeout事件由业务方决定是重新请求还是恢复原状态。这个机制在弱网环境下非常有用防止组件“卡死”。3.3 状态组合的优先级与边界规则这是 RcSwitch 状态系统里最有价值的部分。disabled 和 loading 同时为 true 时应该表现成什么样我针对这个问题做了详细的优先级梳理。先定义状态枚举export enum RcSwitchState { Off off, // 关闭 On on, // 开启 TurningOn turning_on, // 正在开启加载 TurningOff turning_off, // 正在关闭加载 Disabled disabled // 禁用 }把状态从“两个布尔值的排列组合”收敛成一个枚举最大的好处是渲染逻辑只需要对一种状态做判断不会出现互相覆盖的问题。优先级规则如下Disabled是最高优先级。只要 disabled 为 true无论 loading 是否在进行交互都必须完全禁止。但 loading 的“视觉”可以继续存在。也就是说如果组件先进入TurningOn然后外部又把它设为 disabled此时交互锁住但加载指示器继续转直到外部把 loading 置为 false。反过来如果组件先处于Disabled然后外部又让它 loading视觉上我会强制显示为Disabled不展示加载动画。理由很简单用户已经明确知道这个操作不可执行再转圈反而会产生困惑。这套规则的实践意义在于交互层面看 disabled视觉层面优先尊重“正在发生的状态”。把一个规则说得更直白一点用户在等待结果时不要用禁用态把它“静音”用户没有操作权限时不要用加载动画制造虚假希望。3.4 受控模式与非受控模式的选择在状态管理上RcSwitch 选择了受控模式。也就是说checked和loading都不是组件内部自己维护的State而是由父组件通过属性传入组件只负责展示和回调。Component export struct RcSwitch { Prop checked: boolean false; Prop enabled: boolean true; Prop loading: boolean false; Prop loadingTimeout: number 5000; onChange: (value: boolean) void () {}; onTimeout: () void () {}; }为什么选受控而不是非受控因为开关在真实业务里几乎都是异步联动场景。用户拨动开关后组件需要先把“视觉位置”切换到目标位置同时进入 loading 态等接口返回成功后才确定最终状态。如果 checked 是组件内部自行维护的外部就无法在接口失败时把状态“回滚”到原来的位置。受控模式唯一的问题是写起来麻烦父组件要维护一堆状态。但它换来的是极大概率的状态可控性这个取舍我认为是值得的。4. 核心实现解析属性定义、状态机与动画时序前面两章讲的是设计思路这一章进入实操层面把 RcSwitch 的核心实现拆开看。4.1 组件整体结构与属性定义RcSwitch 的整体结构是一个Stack容器底层是轨道圆角矩形上层是滑块圆形加载指示器则叠加在滑块之上。这种布局的优点是绘制逻辑简单轨道和滑块的动画可以分别控制。组件的对外属性除了前面提到的checked、enabled、loading、loadingTimeout之外还有一组控制颜色和尺寸的属性property theme: SwitchTheme defaultSwitchTheme; property width: Length 52; property height: Length 32; property thumbDiameter: Length 28;SwitchTheme是一个普通类包含所有语义色export class SwitchTheme { trackOnColor: ResourceColor $r(app.color.switch_track_on); trackOffColor: ResourceColor $r(app.color.switch_track_off); trackOnDisabledColor: ResourceColor $r(app.color.switch_track_on_disabled); trackOffDisabledColor: ResourceColor $r(app.color.switch_track_off_disabled); thumbColor: ResourceColor Color.White; thumbDisabledColor: ResourceColor $r(app.color.switch_thumb_disabled); loadingColor: ResourceColor $r(app.color.switch_loading); loadingDisabledColor: ResourceColor $r(app.color.switch_loading_disabled); }4.2 动画时序先滑后变还是边滑边变开关动画最核心的视觉点是滑块从一侧滑到另一侧的过程中轨道颜色如何变化。这里有两种做法一种是滑块滑动到位后再变轨道颜色另一种是边滑动边变轨道颜色。RcSwitch 采用的做法是后者但时间节奏做了区分。我实际用的动画参数是滑块位移 200ms轨道颜色渐变 150ms。滑块先开始动轨道颜色稍微滞后一点但基本同时进行。这样视觉上更顺滑不会有“滑块到了颜色才跟上”的割裂感。加载态转圈动画是另一个独立的动画我用的是Canvas绘制圆弧通过属性动画改变圆弧的起始角度和结束角度State private loadingAngleStart: number 0; State private loadingAngleEnd: number 60; // 每帧更新角度实现转圈效果 private startLoadingAnimation() { this.loadingAnimation setInterval(() { this.loadingAngleStart (this.loadingAngleStart 8) % 360; this.loadingAngleEnd (this.loadingAngleEnd 8) % 360; }, 16); }这里要注意一点加载指示器不要用透明度闪烁的方式做“伪 loading”视觉上看起来很廉价而且用户感知不到“正在请求”的真实进度感。旋转圆弧的指向性更强符合“操作进行中”的语义。4.3 状态切换的防抖与打断策略动画做多了就会遇到“动画被打断”的问题。最典型的一个场景是滑块正在从 On 滑向 Off 的过程中由于接口快速返回外部立刻把 checked 状态又切回 On。这时候如果不做处理滑块在被中断的动画上继续执行新的动画位置可能卡在一个中间值上。我的处理方式是在每次状态切换前先调用一次animateTo的关闭方法把上一次动画强制结束然后再发起新的动画。在 ArkTS 里可以通过animateTo的finishCallback来感知上一次动画的结束时机确保动画不叠加private animateToState(targetState: RcSwitchState) { // 先取消上一次动画回调 if (this.pendingAnimationFinish) { this.pendingAnimationFinish(); this.pendingAnimationFinish null; } animateTo({ duration: 200, curve: Curve.EaseOut, finishCallback: () { this.pendingAnimationFinish null; }}, () { this.currentState targetState; }); }4.4 为什么不用系统 Toggle 而是自定义绘制可能会有人问ArkUI 自带Toggle组件直接设置SwitchType不就行了为什么还要自绘我的回答是系统 Toggle 在基础场景够用但它对自定义颜色、加载态、禁用组合态的扩展成本太高。比如 Toggle 中无法直接在滑块上叠加一个 loading 圆弧也无法精细控制“切换过程中轨道颜色的中间态”。如果你只是内部工具 App 用完全不考虑视觉定制用系统 Toggle 完全没问题。但如果你在做一个需要面向多个业务方的组件库或设计系统自定义绘制几乎是唯一可靠的选择。自定义绘制的代价是代码量增加但 RcSwitch 的整体代码量也就 400 行左右换来的是完全可控的视觉和交互细节这个投入产出比是很划算的。5. 半年踩坑实录真机测试中遇到的典型问题这半年踩过的坑我挑几个有代表性的拿出来说说这些问题在文档里基本找不到现成答案遇到了只能靠真机排查。5.1 深色模式下的颜色“发脏”问题这个问题在 1.1 节提到过这里说下排查过程。最初切深色模式后开关的灰显区域看起来有一层“脏”的颜色像蒙了灰一样。后来逐个颜色排查发现是禁用态用透明度叠加导致的透明灰叠在深色轨道上色值产生了不可预期的混合。解决方式是前面讲到的统一换用禁用专用色。排查时有一个小技巧在真机上开启开发者选项里的“显示点按响应”和“布局边界”能直观看到组件实际渲染区域的色块有助于快速定位颜色异常是组件问题还是外部容器背景影响。5.2 加载态转圈时出现掉帧早期版本加载圆弧用的是每秒更新角度值的setInterval方案在低端机上肉眼可见的掉帧。后来我把角度更新从setInterval改为使用 ArkUI 的显式动画驱动让框架接管每一帧的刷新掉帧问题大幅缓解。具体做法是不再手动设置loadingAngleStart和loadingAngleEnd而是通过animateTo让圆弧的结束角度 360 度循环增长每次增长时重新触发动画。这样帧率交由系统统一调度不会受 JS 线程定时器精度影响。5.3 连续点击导致的请求风暴这个问题的根源是早期版本没有 loading 状态的交互锁。用户快速拨动开关三下三份网络请求同时发出后端收到三份互相矛盾的指令最终状态被最后返回的那份请求覆盖界面和实际设备状态不一致。加交互锁是最直接的解决方案组件一旦进入TurningOn或TurningOff就拒绝一切新的点击事件直到状态机切回稳态或者超时。这个锁不需要额外变量因为在状态机枚举里已经天然包含了。5.4 无障碍模式下开关语义不清晰辅助功能TalkBack开启后开关如果只是读“开关”用户完全不知道它控制的是什么功能。RcSwitch 增加了accessibilityLabel属性比如“消息免打扰开关”同时accessibilityState会如实反映当前是否选中、是否禁用、是否忙碌。这些属性不仅在无障碍场景有用在 UI 自动化测试里也能提升元素定位的准确性。5.5 常见问题速查表问题根因解决方案深色模式颜色浑浊禁用态用透明度叠加改用专用禁用色连续点击重复请求无加载态交互锁状态机中增加 Turning 状态动画打断后滑块错位旧动画未取消动画前强制结束上一次动画加载转圈掉帧定时器驱动动画改为动画框架驱动资源颜色解析失败组件库资源未正确合并统一放置资源并核对路径深浅色切换不生效硬编码 Color 值改用资源引用6. 验收清单与后续扩展方向最后分享一套 RcSwitch 的验收清单每次改动后我都会照着跑一遍半年下来这套清单帮我避免了至少三次线上事故。6.1 真机必测场景浅色模式和深色模式下分别测试开启、关闭、禁用、加载四种基础形态快速连续点击开关确认不会出现重复回调在弱网环境下测试加载态超时自动恢复切换过程中快速改变 checked 值确认动画不会卡在中间态开启无障碍模式确认 TalkBack 读屏语义正确自定义主题色后检查激活轨道色和滑块颜色的对比度6.2 后续可扩展的方向目前 RcSwitch 的颜色系统已经支持基础的主题色推导后续我准备扩展的方向有三个。第一是支持多主题切换。目前的 SwitchTheme 是一次性注入的后续可以配合全局主题管理在运行时动态替换整套色板。第二是增加尺寸规范化。开关在不同场景下的尺寸规范应该从设计侧统一维护组件内部只做有限个档位而不是允许任意缩放。第三是提供状态变化的事件流。目前 onChange 只回调 boolean 值后续想增加onStateChange回调把完整的状态机变化暴露给外部便于埋点统计和调试。开关组件看起来是 UI 组件里最简单的那一类但真正把它做到“让人放心”的程度需要的不是炫技而是对每一个交互细节的较真。这半年磨下来的最大收获不是 RcSwitch 本身而是养成了“不放过任何一个状态组合”的习惯。日常开发中用户感知到的“好用”往往就是这些细节堆出来的。
返回列表