ARTICLE DETAIL

资讯详情

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

OpenHarmony上React Native数值输入Hook:useNumber设计与踩坑实录

OpenHarmony上React Native数值输入Hook:useNumber设计与踩坑实录 在 OpenHarmony 上跑 React Native最磨人的往往不是业务逻辑而是那些看起来不起眼的输入控件。特别是数字输入——商品数量、价格、分数、年龄、经纬度哪个页面少了数值输入都难受。我在把一套 RN 页面迁到 OpenHarmony 真机时发现原生 TextInput 的键盘类型、最大长度限制在部分版本上并不像安卓/iOS 那样稳定这才下了决心把数值校验收进一个自定义 Hook 里于是有了 useNumber。这篇就聊聊 useNumber 的设计过程、在 OpenHarmony 上的踩坑经验以及一套可以直接抄下来用的代码。它不是一个“读一下思路”的抽象概念而是连边界条件都处理好的可落地实现。适合正在做 OpenHarmony RN 跨端开发、或者想在 RN 项目里统一数值输入逻辑的开发者参考。不管你是刚在模拟器上把空白工程跑起来还是已经折腾了几个月的真机适配这篇文章都会对你有帮助。1. 先想清楚再动手为什么数值校验不能只靠原生控件1.1 原生控件限制与 RN 桥接的差异做 RN 开发的人经常默认一件事TextInput keyboardTypenumeric /就能拿到数字。在安卓和 iOS 上这套逻辑大体上没问题但在 OpenHarmony 的适配层上事情就没那么简单了。OpenHarmony 的 RN 运行时社区通常叫 RNOHReact Native OpenHarmony把 JS 侧的 TextInput 映射到 ArkUI 的 TextInput 组件上键盘类型、maxLength、软键盘的确认按钮类型这些属性需要底层桥接层逐一转换。我实测下来部分 OpenHarmony 版本上keyboardTypenumeric弹出的仍然可能是全键盘maxLength在某些输入法组合下也可能出现“中文输入法候选词越过长度限制”的怪现象。这时候如果业务侧没有兜底用户就能把字母、特殊符号、甚至整段话输进去。等到提交时后端返回 400你再去排查接口参数浪费的时间远比封装一个 Hook 要多。所以与其依赖“键盘类型 maxLength”这种组合拳不如在逻辑层加一道数值闸门。我们要保证不管键盘弹出来是什么样用户输进去的内容必须经过清洗不符合数值规则的一律拦掉。1.2 自定义 Hook 的边界划分在动手写代码前我明确了一条原则Hook 只负责“数值输入的状态管理与约束”不负责 UI 渲染。这样设计的好处是你可以在任何 TextInput 上用它也可以封装成带前缀后缀的货币输入框、步进器、滑杆数值显示UI 怎么变逻辑不用动。具体边界如下输入清洗去掉非数字字符、处理多个小数点、处理负号位置。范围钳制超过 min/max 时自动拉回边界值但要注意“输入过程中”和“失焦后”两个阶段的差异化处理。格式规范整数模式不允许小数点小数模式限制精度位数。状态输出给 TextInput 提供value和onChangeText再暴露isValid、numberValue等派生状态。不做什么也重要不做“千分位格式化”因为输入过程中格式化会带来光标跳动问题体验很难做好不做 Toast 弹窗和错误展示因为不同业务对错误状态的呈现方式不一样工具层不应该替业务做决定。1.3 设计目标与方案选型确定边界之后我梳理出了 useNumber 的核心设计目标第一收敛性。所有数值输入规则在一个地方维护不管页面里有多少个数字输入框行为保持一致。第二可控性。使用者能配置 min、max、integer、precision、允许负数等选项而不是打开源码改逻辑。第三稳定性。在 OpenHarmony 的 TextInput 事件异常时能通过清洗函数把脏数据挡在状态之外。方案选型上我决定用 useState 管理字符串而不是直接用 number。原因是用户输入“12.” 或者 “-” 的时候如果状态是 number这些中间态根本无法表达。字符串状态才符合真实输入过程。2. 能把 Demo 跑起来设备树、模拟器与白屏排查2.1 设备树到底怎么选rk3568 的“选择困难症”很多人在 OpenHarmony 环境上栽的第一个跟头不是代码而是板子跑不起来。尤其是 rk3568 这类芯片网上能搜到的开发板型号五花八门源码里的设备树文件也一堆什么 evb1、evb2、evb3还有不同厂家定制的板型。选错了轻则 WiFi/蓝牙不工作重则内核跑飞、串口无输出。我这里的排查顺序供你参考先用串口或 adb 进入系统执行cat /proc/device-tree/model看内核实际加载的板子型号有的板子会直接输出类似“Rockchip RK3568 EVB2 DDR4 V1.0 Board”这样的字符串。如果这一步没有输出看源码里device/board和vendor目录下是否有对应厂家的工程重点看.dts文件的model字段。实在分不清就用 SDK 默认的 rk3568 配置先跑起来因为默认配置通常对应官方 EVB 板外设驱动覆盖范围最全。之后再按板子的实际屏幕、触摸 IC、WiFi 模组调整。选设备树这件事要在开始做 RN 联调前就确认好否则你辛辛苦苦把应用打进去了屏幕不亮或者触摸不动根本没法定位是应用问题还是底层问题。2.2 x86 模拟器与真机的差异有段时间我图方便想直接在 x86 版 OpenHarmony 模拟器上跑 RN 工程。想法很美好但现实是 RN 的某些原生模块依赖 CPU 架构的 so 库Release 包在 x86 上加载时经常报“library not found”或者干脆静默失败。我的建议是模拟器用来验证 JS 层业务逻辑可以但涉及原生桥接、相机、定位、键盘弹出等能力最终一定以 ARM 真机为准。如果非要在 x86 模拟器上调试记得检查构建产物里是否包含x86_64的二进制以及 metro 服务是否能在模拟器网络环境下被访问到。别问我为什么强调这一点问就是调了半天发现模拟器访问不了宿主机 8081 端口页面一直白屏。2.3 React Native 启动白屏排查思路“react native 启动白屏”是我搜烂了的关键词。在 OpenHarmony 上出现白屏原因通常集中在这几个地方Bundle 没加载到Dev 模式检查 metro 是否启动以及模拟器/真机和电脑是否在同一网络Release 模式检查index.harmony.bundle是否已正确打包进 hap 资源目录。原生模块注册失败RNOH 的包列表里没有注册自定义模块或者模块初始化抛异常页面渲染到一半被中断。SDK/RN 版本不匹配OpenHarmony SDK 的 API Level 和 React Native 适配版本有对应关系跨大版本组合容易白屏。排查时直接用 hilog 过滤关键词hilog | grep RN能看到加载链路走到哪一步。白屏问题最怕瞎猜日志能帮你把问题范围缩小到“JS 层”还是“原生层”。这套排查思路在我做 useNumber 联调时也反复用到。3. useNumber 实现细节从基础版到完整版的核心代码3.1 基础版min/max 范围钳制先给一个最简可用的版本。它做的事情很纯粹清洗输入字符串用 parseFloat 转成数字超出 min/max 时把展示值改成边界值。import { useCallback, useState } from react; interface UseNumberResult { value: string; onChangeText: (text: string) void; onBlur: () void; isValid: boolean; } export function useNumber( initialValue: number | string, min?: number, max?: number, ): UseNumberResult { const [value, setValue] useStatestring(String(initialValue ?? )); // 清洗只保留数字、小数点和负号 const clean useCallback((text: string): string { if (text ) return ; let result text.replace(/[^0-9.-]/g, ); // 负号只允许出现在第一位 const minusIndex result.indexOf(-); if (minusIndex 0) { result result.slice(0, minusIndex) result.slice(minusIndex 1); } if (minusIndex 0 result.indexOf(-, 1) ! -1) { result - result.slice(1).replace(/-/g, ); } // 多个小数点时只保留第一个 const firstDot result.indexOf(.); if (firstDot ! -1) { result result.slice(0, firstDot 1) result.slice(firstDot 1).replace(/\./g, ); } return result; }, []); const clamp useCallback( (n: number): number { let result n; if (min ! undefined result min) result min; if (max ! undefined result max) result max; return result; }, [min, max], ); const handleChangeText useCallback( (rawText: string) { const cleaned clean(rawText); if (cleaned ) { setValue(); return; } const num parseFloat(cleaned); if (Number.isNaN(num)) return; // 输入阶段如果值合法就直接更新超范围则钳制 const clampedNum clamp(num); if (clampedNum num) { setValue(cleaned); } else { setValue(String(clampedNum)); } }, [clean, clamp], ); const handleBlur useCallback(() { if (value ) { setValue(min ! undefined ? String(min) : ); return; } const num parseFloat(value); if (Number.isNaN(num)) return; setValue(String(clamp(num))); }, [value, min, clamp]); const num parseFloat(value); const isValid !Number.isNaN(num) (min undefined || num min) (max undefined || num max); return { value, onChangeText: handleChangeText, onBlur: handleBlur, isValid, }; }这里面有一个很容易被忽略的点parseFloat(-)返回的是NaN而用户在输入负数时第一步就是要单独输入一个负号。如果onChangeText里遇到NaN就直接 return用户会发现负号根本打不进去。所以我在cleaned 时允许直接 setValue 为空字符串但-会被 parseFloat 解析成 NaN 直接丢弃。针对这个问题更稳妥的做法是把-这种“不完整但合法”的中间态放行if (cleaned - || cleaned .) { setValue(cleaned); return; }3.2 完整版支持整数、小数位与负数开关基础版只解决了范围问题实际业务里还有一些高频诉求只能输入整数、保留两位小数、禁止负数。这些规则如果每个页面写一遍一定有人漏判。完整版把这些规则收敛进 options用起来就省心很多。export interface UseNumberOptions { min?: number; max?: number; integer?: boolean; precision?: number; allowNegative?: boolean; defaultValue?: number | string; } export function useNumber(options: UseNumberOptions {}) { const [value, setValue] useStatestring( String(options.defaultValue ?? ), ); const clean (text: string): string { if (text ) return ; let result text.replace( options.allowNegative ? /[^0-9.-]/g : /[^0-9.]/g, , ); // 负数处理 if (options.allowNegative) { const minusCount result.split(-).length - 1; if (minusCount 1) { result - result.replace(/-/g, ); } else if (result.indexOf(-) 0) { result result.replace(/-/g, ); } } // 整数模式直接去掉小数点 if (options.integer) { result result.replace(/\./g, ); return result; } // 多个小数点的清理 const firstDotIndex result.indexOf(.); if (firstDotIndex ! -1) { result result.slice(0, firstDotIndex 1) result.slice(firstDotIndex 1).replace(/\./g, ); } // 小数位精度限制 if (options.precision ! undefined firstDotIndex ! -1) { const parts result.split(.); if (parts[1].length options.precision) { parts[1] parts[1].slice(0, options.precision); result parts.join(.); } } return result; }; const handleChangeText (rawText: string) { let cleaned clean(rawText); if (cleaned || cleaned - || cleaned .) { setValue(cleaned); return; } // 负号后面还没数字时直接放行 if (cleaned - options.allowNegative) { setValue(cleaned); return; } const num parseFloat(cleaned); if (Number.isNaN(num)) return; // 输入期间的钳制策略对于负数场景要防止 min 是 0 时把负数拦掉 if ( options.min ! undefined options.max ! undefined options.min 0 parsedNum options.min ) { // 不立刻钳制交给 onBlur 处理避免用户输入 0.05 时首位 0 被误伤 setValue(cleaned); } else { setValue(String(Math.min(options.max ?? num, Math.max(options.min ?? num, num)))); } }; const handleBlur () { if (value || value - || value .) { setValue(options.defaultValue ! undefined ? String(options.defaultValue) : ); return; } const num parseFloat(value); if (Number.isNaN(num)) return; let final num; if (options.min ! undefined final options.min) final options.min; if (options.max ! undefined final options.max) final options.max; if (options.integer) { final Math.trunc(final); } if (options.precision ! undefined) { final parseFloat(final.toFixed(options.precision)); } setValue(String(final)); }; const numberValue value ! value ! - value ! . ? parseFloat(value) : NaN; const isValid !Number.isNaN(numberValue) (options.min undefined || numberValue options.min) (options.max undefined || numberValue options.max); return { value, onChangeText: handleChangeText, onBlur: handleBlur, isValid, numberValue, }; }写到这里要解释一下为什么handleChangeText里的钳制逻辑写得这么“别扭”。我最初是用最简单的 clamp-on-change也就是输入“55”时如果 max 是 50立即变成“50”。但实际联调后发现这种策略在金额输入场景下非常反人类用户想输入“5.05”刚输入完“5.0”光标还在中间值就被解析成 5 直接钳到 max 或保留为 5.0下一步输 5 的时候字符串状态混乱光标还会自动跳到末尾。所以完整版里我做了一个关键决策输入态允许暂时越界失焦时才强制修正。你可以在页面提交按钮上通过isValid判断当前值是否合法并给出提示也可以在 onBlur 里自动修正。这样既保证用户体验顺滑又确保最终提交给接口的一定是合法值。3.3 用 useCallback 和 useMemo 优化性能OpenHarmony 上的 RN 应用性能和主流移动端还是有差距的特别是在低配 RK3568 设备上页面渲染一次卡顿感很明显。所以 Hook 内部我建议用useCallback把函数引用稳定下来避免父组件每次渲染都生成新的函数导致 TextInput 重新渲染。这里有一个容易踩的坑如果clean函数依赖了options对象而每次 render 时你传入的都是一个字面量{ min: 0, max: 100 }那么useCallback的依赖数组里写[options]就完全没用因为对象引用每次都在变。我在实际项目里的解法是把 options 拆开在 Hook 入参上直接接收min、max、precision这些原始值内部再聚合成配置对象。这样依赖数组可以精确控制export function useNumber( options: UseNumberOptions {}, ) { const { min, max, integer, precision, allowNegative, defaultValue } options; // 之后的 useCallback 依赖 [min, max, integer, precision, allowNegative, defaultValue] }另外numberValue和isValid这两个派生值每次 render 都计算一次成本很低不需要useMemo。但如果后续你在表单页里用它做联动校验比如价格下限不能大于上限可以把计算提取成一个纯函数getNumberState(value, options)方便在多个 Hook 之间共享。4. 页面落地实战三个高频场景的联动写法4.1 商品数量输入整数 最小值兜底电商购物车、库存盘点、预约人数这些场景的输入框需求高度一致只能输正整数最小是 1最大是库存数。用 useNumber 封装出来是这样的const { value, onChangeText, onBlur, isValid } useNumber({ min: 1, max: 99, integer: true, defaultValue: 1, }); TextInput value{value} onChangeText{onChangeText} onBlur{onBlur} keyboardTypenumber-pad placeholder请输入数量 /注意keyboardType这里我用的是number-pad而不是numeric从 OpenHarmony 的适配表现来看number-pad在多数版本上能弹出更干净的数字键盘减少用户切换到符号键盘输入小数点的概率。不过这个属性在不同版本上表现不一致所以别把它当成安全边界真正的安全边界是 useNumber 的 clean 逻辑。4.2 价格区间输入带小数点与 max 校验价格输入要比数量输入更精细因为用户可能输入“10.5”这种带小数的值而且价格区间还要联动校验最低价不能高于最高价。设计时价格框用precision: 2失焦后自动保留两位小数。const minPrice useNumber({ min: 0, max: 9999, precision: 2, defaultValue: , }); const maxPrice useNumber({ min: 0, max: 99999, precision: 2, defaultValue: , }); // 提交时下限不能大于上限 const isRangeValid minPrice.isValid maxPrice.isValid (maxPrice.numberValue minPrice.numberValue || Number.isNaN(minPrice.numberValue)); TextInput value{minPrice.value} onChangeText{minPrice.onChangeText} onBlur{minPrice.onBlur} keyboardTypedecimal-pad placeholder最低价 / TextInput value{maxPrice.value} onChangeText{maxPrice.onChangeText} onBlur{maxPrice.onBlur} keyboardTypedecimal-pad placeholder最高价 /这里最容易出问题的是 min 为 0 时的空输入。用户把内容清空后按我前面说的逻辑空字符串会被放行因为用户可能正在重新输入。但如果此时有人在失焦时把 value 设成 0用户再点进输入框删掉 0 想输新值就会一直被“钳制”回来体验非常差。所以我的做法是defaultValue 为空字符串时失焦不自动填 0只有显式传入 defaultValue 才回填。4.3 步进器联动按钮加减与输入框共用一套状态步进器场景下加减按钮操作的是数字输入框操作的是字符串两者如何保持同步核心思路是用numberValue计算下一步的值然后反向调用onChangeText把新的数字字符串写回状态。const quantity useNumber({ min: 1, max: 99, integer: true, defaultValue: 1, }); const step 1; const handleIncrease () { if (Number.isNaN(quantity.numberValue)) return; const next Math.min(99, quantity.numberValue step); quantity.onChangeText(String(next)); }; const handleDecrease () { if (Number.isNaN(quantity.numberValue)) return; const next Math.max(1, quantity.numberValue - step); quantity.onChangeText(String(next)); }; View style{styles.stepper} Pressable onPress{handleDecrease} style{styles.stepBtn} Text-/Text /Pressable TextInput value{quantity.value} onChangeText{quantity.onChangeText} onBlur{quantity.onBlur} style{styles.stepInput} / Pressable onPress{handleIncrease} style{styles.stepBtn} Text/Text /Pressable /View这套写法的好处是步进器和手动输入共用同一套清洗、钳制、校验逻辑不会出现“按钮加出 100、手输只能到 99”的分裂状态。我在实际项目里还把handleIncrease、handleDecrease用useCallback包了一层避免步进器组件频繁重渲染。5. 真机踩坑实录问题速查表与独家避坑经验5.1 常见问题速查表现象可能原因解决方案输入字母也能进状态只用了 keyboardType 限制没做逻辑清洗用 useNumber 的 clean 函数过滤非法字符达到 max 后无法继续删除每次 onChange 都 clamp输入态被强制修正改用 clamp-on-blur 策略输入态允许越界小数位数不受控只校验是否合法没有格式化截断配置precision在 clean 阶段做位数截断输入-时直接没反应parseFloat(-) 返回 NaN被 return 拦截将-、.作为合法中间态放行中文输入法下长度限制失效原生 maxLength 和组合输入逻辑冲突把 max 判断放在 JS 层失焦时统一修正快速连续输入时卡顿onChangeText 链路长频繁 setState用 useCallback 稳定函数引用避免无关渲染真机上 onChangeText 拿到 Object少数 OpenHarmony 版本桥接类型不稳定加一层String(text)强制转换再处理5.2 独家避坑经验三个真正值钱的小技巧第一个技巧是“输入态和失焦态的责任分离”。很多开发者写数值校验只做一次校验要么全放在 onChangeText 里要么全放在提交时。onChangeText 里严格钳制会让输入体验支离破碎放在提交时又会让用户困惑“为什么刚才还能看到 999提交就报错”。比较好的做法是onChangeText 负责清洗、onBlur 负责修正、提交前用 isValid 做最终拦截三层各管一段代码不但好写调试也好定位。第二个技巧是“不要轻易做千分位格式化”。我知道给金额加逗号很好看但输入过程中的字符串格式化会带来严重的光标跳动问题。你在 useNumber 的值里插入逗号TextInput 的 value 和用户光标位置就对不上了。如果业务上必须要展示千分位格式我建议用一个只读的展示组件输入框保持原样失焦后的展示值再做格式化。第三个技巧是“在 OpenHarmony 上调试时多留日志”。因为 OpenHarmony 的 RN 生态还不够成熟很多问题的表现非常隐蔽。我在 useNumber 的 clean 函数里临时加过 hilog 输出观察真机上 TextInput 每一帧输入的原始 text 和清洗后的结果。有一次就发现某版本在输入“12.” 后桥接层传给 JS 的值变成了“12”小数点在原生层就被吞掉了。这种问题不看日志根本定位不了靠猜可能要折腾几天。5.3 从 useNumber 到通用表单校验的扩展思路useNumber 解决的是单一输入框的问题但表单校验通常意味着多个输入框之间的联动。比如采购单里“订购数量不能大于库存”或者“折扣价不能高于原价”。这类跨字段校验我建议不要塞进 useNumber 内部而是在表单容器这一层做组合判断。我在项目里常用一个简单的模式每个 useNumber 实例通过numberValue暴露当前数值其他字段通过闭包引用它。举个例子原价输入框的值变化后折扣价框的 max 跟着变这时可以给折扣价框加一个 key让它在原价变化时重新初始化。这个方法看起来很粗暴但比不停的 unregister/register 字段简单得多而且不容易漏掉状态同步。如果字段联动再复杂一些可以引入 react-hook-form 这类的表单库配合自定义register走 Controller 模式。不过我个人提醒一句在 OpenHarmony 的 RN 版本上引入重型表单依赖要先确认对方是否兼容当前的 RNOH 版本避免为了省代码反而引入更多版本的兼容性坑。6. 个人经验这个 Hook 后续还能怎么扩展后面如果再往深做我计划把 useNumber 扩展成 useNumberInput 组件库内置样式统一的数字输入框、步进器和校验提示。同时考虑把精度控制和范围钳制逻辑提取成纯函数集做到不依赖 React 也能单测覆盖。毕竟数值校验这块逻辑分支多边界情况多单元测试的收益非常直接。最后再说一个开发心态上的建议在 OpenHarmony 上做 RN 开发生态确实比安卓要新不少遇到问题不要先怀疑自己的代码。多看看原生实现的源码多打日志复现多查社区 issue很多看起来玄学的问题最后都能定位到某个桥接层的小坑。学会带着“再排查一层”的心态去调试会比急着找人问更有收获。
返回列表