ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React Native鸿蒙跨平台同城货运App实践:状态机与司机匹配

React Native鸿蒙跨平台同城货运App实践:状态机与司机匹配

做同城货运App这两年,最绕不开的两个模块就是订单生命周期管理和司机匹配。最近我们项目组基于React Native做了一版鸿蒙跨平台适配,司机端、用户端共用一套JS业务代码,把订单创建、重量计价、车型筛选、司机指派、状态流转全部打通。这篇文章是我们从立项到上线压测的完整记录,重点讲三件事:基于重量分档的自动计价规则怎么落地,按车型等条件筛选并排序司机的匹配逻辑怎么设计,以及订单状态机在React Native和鸿蒙端之间如何保持一致。里面也包括一些文档里不写的坑,比如鸿蒙原生桥接的权限适配、状态回跳的版本号控制,希望能给正在做类似跨平台业务的人一些参考。

1. 项目背景与整体架构设计

1.1 为什么选React Native做鸿蒙跨平台

我们当时同时要维护Android、iOS、鸿蒙三端,业务逻辑又重又复杂。如果每个端都写一套原生代码,订单计价、匹配筛选这些核心规则至少要维护三份,改一个计价规则就要发三个版本,测试也要回归三遍,人力完全扛不住。Flutter和React Native之间也犹豫过,但团队里已有的业务代码大部分是TypeScript写的前端逻辑,直接复用能省很多事,最后敲定React Native作为跨平台框架,鸿蒙端通过社区适配的RN容器来运行同一套bundle。

这里有个容易被忽视的点:React Native鸿蒙适配并不是天上掉下来的,它需要一个独立的运行时容器把JS逻辑映射到鸿蒙的组件和API上。选型的时候我们专门做了一个验证Demo,把现有订单列表页和计价函数跑在鸿蒙模拟器上,确认渲染和交互没有明显问题才正式立项。我的经验是,跨平台方案不能只看谁宣传得好,要拿自己项目里最复杂的三五个页面去实测,重点看列表滚动性能、原生地图组件能不能用、权限申请是否流畅。如果这些核心链路都通了,后续才有信心铺开。

架构上我们分了三层:最上面是RN业务层,订单状态机、计价规则、司机筛选排序全放这里;中间是桥接层,负责定位、推送、相机这些原生能力的调用;最底层是平台原生能力,鸿蒙端跑在它的Ability框架上,Android和iOS各走各的容器。这样的好处是业务代码全在JS层,三端只保留一薄层原生适配,后续改业务基本不用碰原生代码。

1.2 订单生命周期模型拆解

订单生命周期是整个业务的中枢。我们把它拆成7个核心状态:待接单、已接单、司机已到、配送中、已完成、已取消、异常。每个状态都定义好“允许动作”和“目标状态”,比如待接单只能被“司机接单”或者“用户取消”触发,已接单只能被“司机到点”或“用户取消”触发,司机到点之后才能开始配送。这样的状态机设计能直接拦掉很多业务Bug:司机不可能在配送中再次接单,用户取消已完成订单也会被拒绝。

为了不让状态散落在各个页面里,我们用一个纯TypeScript模块管理状态迁移表,每个动作进来先检查当前状态是否合法。比如“开始配送”这个动作,当前状态必须是“司机已到”,如果服务端消息乱序导致本地还停在“已接单”,这个动作会被直接丢弃,同时拉取一次服务端快照纠正本地状态。用这种方式处理网络延迟和重复点击特别有效,比在每个页面里写if判断要干净得多。

不过状态机设计最容易踩的坑是“状态定义得太粗”或者“太细”。一开始我们只定义了待接单、配送中、已完成、取消四个状态,结果运输过程中司机到底到没到点完全看不出来,用户催单时客服也没法快速定位问题。后来拆细到司机已到、异常等状态,才把业务链路做好闭环。建议做状态机之前先拉上运营和客服梳理一遍全流程,把每个业务卡点对应到状态上,宁可状态多一点,也不要上线后靠异常状态来兜底。

1.3 司机智能匹配的流程框架

司机匹配不是一次数据库查询就能搞定的,它是一条流水线。我们设计的完整流程是:订单创建后先锁定候选司机池,接着按车型、载重、空闲状态、距离这四个硬性条件做过滤,过滤完的结果再按接单率、评分、当前任务数等因子打分排序,然后向排序靠前的司机推送订单,等待司机确认;如果某位司机超时未确认,自动顺延到下一位候选司机。

为什么要分段过滤而不是一条SQL查出最终结果?因为司机状态变化很快,前一秒还空闲,下一秒可能就被别的订单抢走了。所以我们把筛选拆成“粗筛”和“精排”两步:粗筛用本地缓存的司机位置、状态数据快速过滤,精排则实时向服务端要最新的接单率、评分等画像数据来计算分数。这样既保证了响应速度,又让推荐结果更贴近实时状况。

这个流程里最容易出问题的是司机池的锁定时机。如果订单创建后不锁定司机池,等到做排序的瞬间司机已经接了其他单,候选池就空了。我们的做法是在订单创建成功后立即快照一份符合硬性条件的司机ID列表,后续所有推送动作都基于这份快照执行,只有快照里的司机确认订单才有效,这样能避免同一时间多个订单抢同一个司机的问题。

2. 基于重量的自动计价逻辑

2.1 计费规则与重量阶梯设计

重量计价是这个业务的核心营收逻辑。我们设计的是“起步价+超出重量单价”的分档计费模型,不同重量区间对应不同的起步价和超出部分单价。简单说,越重的货,平均单价反而越低,但起步价更高,这样能引导用户把大件货交给合适车型,也保证司机不会因为跑一趟太远而亏本。下面是我们当前线上用的默认规则:

重量区间(kg)起步价(元)超出部分单价(元/kg)
0 ~ 50300
50 ~ 200800.6
200 及以上2000.5

注意50kg这个边界值,我们把它划到第二档,也就是说50kg整的订单按第二档起步价80元来计算,而不是第一档。这里有个实际教训:一开始规则用的是“小于等于50”判断,测试数据刚走到50kg时计价总是比预期少50元,排查半天才发现是边界值判断错了。后来我们把重量区间定义为左闭右开,代码里写“大于等于minWeight且小于maxWeight”,才把这个问题彻底解决。

计价规则不能写死在客户端。我们把规则表放在服务端配置,客户端启动时拉取一次缓存到本地,用于订单创建页的预估报价。这样运营调价时不需要发App版本,只要推送新的规则表就能生效。但要注意,规则表的版本管理同样重要,否则用户看到的价格和服务端实际结算的价格可能不一致。我们在配置里加了ruleVersion字段,下单时把版本号一起传到服务端,服务端校验版本不一致就返回提示“价格已更新,请刷新后重试”。

2.2 React Native端实现自动计价计算

计价函数是一个纯函数,输入重量和规则表,输出金额。纯函数最大的好处是容易单测,也方便在React Native和鸿蒙原生端复用同一套结果。下面是我们实际在用的TypeScript实现:

type PricingRule = { minWeight: number; // 档位下限(包含) maxWeight?: number; // 档位上限(不包含),不传表示无上限 basePrice: number; // 起步价 extraPerKg: number; // 超出部分单价,单位:元/kg }; const DEFAULT_RULES: PricingRule[] = [ { minWeight: 0, maxWeight: 50, basePrice: 30, extraPerKg: 0 }, { minWeight: 50, maxWeight: 200, basePrice: 80, extraPerKg: 0.6 }, { minWeight: 200, maxWeight: undefined, basePrice: 200, extraPerKg: 0.5 }, ]; export function calculatePrice(weightKg: number, rules: PricingRule[] = DEFAULT_RULES): number { const rule = rules.find( r => weightKg >= r.minWeight && (r.maxWeight === undefined || weightKg < r.maxWeight) ); if (!rule) { return 0; } const extraWeight = weightKg > rule.minWeight ? weightKg - rule.minWeight : 0; const extraFee = extraWeight * rule.extraPerKg; const total = rule.basePrice + extraFee; // 规避浮点数误差,保留两位小数 return Math.round(total * 100) / 100; }

这段代码有两个细节值得展开。第一,单位必须统一成kg。App里可能接到的是吨,也可能接到的是斤,但我们内部所有运算都先转成kg,计价函数入口就严格要求kg,一旦传错单位整个价格就错了。我们配套写了一个unitConvert函数,专门处理吨、斤、公斤之间的转换,并加了单元测试。第二,浮点运算一定要做舍入处理。0.6乘以50之类的计算在JS里会出现类似29.999999的结果,直接用生成的价格会很难看,也可能导致服务端校验时不一致。我们统一用“先乘100再Math.round再除以100”的方案,保证展示金额和服务端结算金额一致。

下单接口提交时,客户端会把重量、规则版本号、预估价格一起传给服务端。服务端拿到订单后重新按最新规则计算一遍真实价格,前后端不一致时以后端为准。这样即使客户端被反编译改了函数,也影响不了实际结算。

2.3 边界情况处理经验

计价模块的边界情况比想象中多得多。重量为负数、重量为0、重量超过最大档位上限、重量刚好卡在档位边界,这些都要单独验证。我们测试组当时给出一份很长的边界用例表,其中最容易翻车的是“0kg订单”:用户在填写货物信息时会话中断,重新进来重量没同步,下单时提交了0kg。计价函数直接返回起步价30元,但司机去了之后发现没货可拉,纠纷马上就来了。后来我们加了前置校验,下单重量必须大于0,且小于等于所选车型的最大载重,否则直接弹窗提示“请检查货物重量”。

还有一个实战问题是App里的“预估重量”和“实际重量”不一致。很多用户随便估了一个重量下单,司机到了现场才发现货物远超预估,此时如果重新计价,用户觉得贵了,司机觉得亏了,双方都要安抚。我们的折中方案是:允许用户在下单后但司机接单前修改一次重量,并重新计算价格;一旦司机已接单,重量修改必须走客服审核。这样把计价冲突尽量控制在接单前解决。

经验总结:计价逻辑这种核心业务,宁可写起来烦一点,也不能图省事。边界判断、单位转换、版本控制、服务端校验,一个都不能少。我们当时把计价函数抽成独立模块,写了80多个单测用例,上线后因为计价问题引发的客诉明显比其他模块少。

3. 按车型筛选司机与匹配策略

3.1 车型数据模型和筛选条件

司机端的车型信息是整个匹配系统的地基。我们用了一个简化的车型字典:小型面包车、中型面包车、厢式货车、平板货车,每种车型都配置了载重上限、车厢体积、车长和能否装卸货。订单创建时,用户选择货物类型和重量,系统自动推荐可用车型,再根据这个车型要求去找司机。

车型筛选属于硬性条件,不满足就淘汰。过滤条件我们写成了下面这个函数,这也是粗筛阶段的核心逻辑:

type DriverStatus = 'IDLE' | 'BUSY' | 'OFFLINE' | 'COMPLETING'; function filterAvailableDrivers( drivers: Driver[], order: Order ): Driver[] { const pickupRadiusKm = order.pickupRadiusKm || 15; return drivers.filter(driver => { const meetsFleetType = driver.vehicle.fleetType === order.requiredFleetType; const meetsLoad = driver.vehicle.maxLoadKg >= order.weightKg; const isIdle = driver.status === 'IDLE'; const isWithinRange = getDistanceKm(driver.lat, driver.lng, order.pickupLat, order.pickupLng) <= pickupRadiusKm; return meetsFleetType && meetsLoad && isIdle && isWithinRange; }); }

这里有一个实际业务摩擦点:车型完全匹配往往会导致候选池过小,尤其晚高峰货车数量有限。后来我们增加了“车型兼容”机制,允许用户在下单时勾选“允许相近车型接单”,比如厢式货车可以接平板货车的单,但载重上限必须严格满足。这个逻辑也让匹配成功的司机单量提升了不少。我的建议是,车型硬性条件里只有“载重上限”绝对不能放开,因为它涉及行车安全,其他条件都可以做成可选放宽项,由用户在下单页自己决定。

3.2 候选司机排序与智能推荐

粗筛出来的司机可能还有几十个,到底先推给谁?我们用了加权评分排序。核心思想是把距离、接单率、服务评分、当前任务数这些因子做归一化,然后按业务权重算出总分数。简化后的打分公式大致是这样:

function scoreDriver(driver: Driver, order: Order): number { const distanceScore = Math.max(0, 100 - getDistanceKm(driver.lat, driver.lng, order.pickupLat, order.pickupLng) * 2); const responseScore = driver.acceptRate * 20; // acceptRate 0~1 const ratingScore = driver.rating * 10; // rating 0~5 const loadReduce = driver.activeOrders * 5; // 当前任务数越多扣分越多 return distanceScore + responseScore + ratingScore - loadReduce; }

这个公式看上去简单,真正调参的时候才发现两个问题。第一,不同因子的量纲差异很大,比如距离差2公里能造成4分差距,而评分差0.5分只有5分差距,导致距离权重实际影响大于设计值。所以必须先做归一化:距离除以最大可接单半径,评分除以5,接单率本身就是0到1,最后再乘各自权重。第二,不能只看静态画像,还要看实时路况。我们第一版只按直线距离排序,结果经常把司机推到拥堵路段,导致到达时间延误。后来接入高德的路径规划API,把预计运输时长作为一个新的排序因子,匹配准确度才明显提升。

排序之后还有一个推送策略的问题。一次推送太多司机,会造成同一订单被很多人点到,造成“手快有手慢无”的体验,也很容易引发投诉。我们采用“分批推送”机制:第一批推送排序前3名,10秒内没有司机确认,再推送第4到第6名,以此类推。这样既给司机留出决策时间,又避免订单在系统里挂太久。

3.3 匹配超时与兜底策略

匹配不是永远都能成功的。我们最常遇到的情况是:凌晨用车少、极端天气司机都不出车、偏远地区候选池本来就很空。针对这些情况,有三层兜底方案。

第一层是超时顺延,上面说的每10秒追加一批司机,最多循环三次。第二层是自动扩大搜索半径:初始半径15公里,匹配超时后扩大到25公里,同时放宽“车型兼容”选项。第三层是转入人工调度池,由调度员手动在后台指派司机,同时给用户推送“司机较少,预计等待时间较长”的提示,让用户可以选择加价呼叫。

我们踩过最大的坑是“订单挂死”。当时匹配逻辑有Bug,推送司机后如果司机不确认也不拒绝,订单就会一直卡在PUSH_PENDING状态,既不超时,也不进入下一批。后来我们明确了一个原则:所有推送动作必须带超时定时器,超时后强制触发“无响应”事件,状态机收到事件后把司机标记为未响应,然后顺延下一位。同时给这个流程加了一个监控看板,一旦某个订单等待司机确认超过3分钟就自动告警,人工介入。

4. 订单生命周期状态管理实践

4.1 状态机设计:从待接单到异常处理

订单生命周期管理最怕状态乱跳,所以我们把所有状态转移收敛到一个状态机里。下面是我们最终的简化状态表:

当前状态允许动作目标状态备注
待接单(CREATED)司机接单已接单司机必须匹配且未被其他订单抢走
待接单(CREATED)用户取消已取消取消原因必填
已接单(ACCEPTED)司机到点司机已到需要上传定位快照
已接单(ACCEPTED)用户取消已取消可能产生取消费
司机已到(ARRIVED)开始配送配送中司机点击后记录发货时间
配送中(IN_TRANSIT)确认完成已完成需要上传送达照片
任意状态系统监测异常异常超时、司机失联、货物异常

状态机的TypeScript实现并不复杂,核心是一个迁移表加一个迁移方法。我们要求所有状态变更必须通过reducer分发action,禁止在UI组件里直接改状态变量。举一个典型例子:司机点击“开始配送”时,会dispatch一个START_DELIVERYaction,状态机校验当前状态是ARRIVED后,把状态改为IN_TRANSIT,同时记录时间戳。如果当前状态不是ARRIVED,就直接返回旧状态,并抛出一个警告日志。

const transitions: Record<OrderState, Partial<Record<OrderAction, OrderState>>> = { CREATED: { ACCEPT: 'ACCEPTED', CANCEL: 'CANCELLED' }, ACCEPTED: { ARRIVE: 'ARRIVED', CANCEL: 'CANCELLED' }, ARRIVED: { START_DELIVERY: 'IN_TRANSIT' }, IN_TRANSIT: { COMPLETE: 'COMPLETED' }, COMPLETED: {}, CANCELLED: {}, ABNORMAL: {}, }; export function orderReducer(state: OrderState, action: OrderAction): OrderState { const next = transitions[state]?.[action]; if (!next) { console.warn(`[StateMachine] 非法迁移: ${state} + ${action}`); return state; } return next; }

这段代码看起来简单,但价值很大。之前订单状态乱跳导致的“幽灵单”问题基本都是因为缺少这一层统一校验。比如用户取消和司机接单同时发生时,网络请求先后顺序不确定,如果没有状态机,本地状态可能先变成已接单,又被取消事件覆盖成已取消;有了状态机后,取消事件在已接单状态下是允许的,但接单事件在已取消状态下会被拒绝,顺序就不会影响最终结果。

4.2 React Native与鸿蒙端的状态同步

订单状态最终权威在服务端,客户端本地状态只是一份缓存。React Native业务层和鸿蒙原生层之间常常会因网络延迟产生状态不一致,最常见的就是“状态回跳”:用户看到订单已经是配送中,又突然跳回已接单。查下来原因是服务端推送和客户端本地更新发生了乱序,旧消息把新状态覆盖了。

我们的解决方案是引入statusVersion版本号。服务端每发生一次状态变更,就把版本号加1并随订单数据下发。客户端保存本地版本号,收到新推送时先比较版本号,只有新版本号大于本地版本号才允许更新状态;否则把推送丢弃并触发一次服务端快照拉取。这样即使旧消息后到,也无法覆盖新状态。这个方案在鸿蒙端同样适用,因为比较逻辑写在RN的JS层,不依赖具体端能力。

还要注意React Native和鸿蒙原生组件的通信时机。鸿蒙端发起定位、扫码等原生动作时,结果会通过Promise或回调传回JS层。这个桥接在状态机里属于异步事件,可能出现用户连续点击多次导致多个回调并发的情况。我们做了一层“幂等处理”:同一订单的同一动作,在短时间内重复触发只保留一次。例如司机点击“到点”按钮,如果2秒内重复点击,第二次直接忽略,避免状态机收到重复的ARRIVE事件后产生副作用。

4.3 异常状态回滚与补偿

即使有状态机保护,客户端和服务端仍然可能因为网络波动出现短暂不一致。这时最重要的是“不能把错误状态展示给用户太久”。我们设计了一个补偿机制:客户端在收到网络错误或者服务端校验失败时,自动进入“乐观锁回滚”。比如用户在下单操作时断网,本地先显示一个“下单中”的中间态,页面按钮置灰;重试3次仍然失败,就把订单状态回滚到下单前的页面,并提示“网络异常,请稍后重试”。

补偿机制的实现要区分“可重试”和“不可重试”的异常。可重试的包括超时、网络中断、服务端5xx错误;不可重试的包括参数校验失败、请求方法不允许、状态机非法迁移。我们维护了一个错误码映射表,在业务层统一捕获错误,根据错误码决定是显示重试按钮还是直接跳转客服。这比每个页面各自处理错误要省力得多。

另外提一个隐藏比较深的坑:订单进入异常状态后,司机端和用户端看到的信息可能不一样。司机端在APP里看到订单卡在“配送中”,用户端却因为收到超时信号已经看到“配送异常”提示。两边都没错,但对不上。我们的做法是把“异常”拆成两个层级:订单内部状态还是IN_TRANSIT,但增加一个displayStatus给用户看到的提示状态。内部状态驱动业务逻辑,展示状态驱动UI文案,两个分开后,很多纠纷都清晰了。

5. 鸿蒙适配与跨平台踩坑实录

5.1 React Native鸿蒙适配的工程配置

React Native跑在鸿蒙上,最麻烦的是工程配置。我们用的方案是基于社区适配层react-native-harmony,把RN容器集成到鸿蒙的主工程里。整体工程结构分两大部分:一部分是标准的React Native项目,负责JS bundle的构建;另一部分是鸿蒙原生工程,负责承载RN容器。鸿蒙工程需要在依赖文件里加入RN容器相关的库,然后把模块名配置成和RN入口一致,这样才能正确加载。

下面是我们工程里Module配置的简化示例:

// 鸿蒙端入口配置示意 const reactNativeHost = new ReactNativeHost() { moduleName: 'OrderApp', bundleAssetPath: 'assets/index_bundle.harmony.js', // 若用远程bundle,则配置bundleUrl entryComponent: 'pages/Index' };

发布时我们把JS bundle打包成离线文件,放进鸿蒙工程的assets目录。这样做的好处是启动时不依赖网络,在电梯、地下车库这些信号差的地方也能正常打开App。代价是每次发版都得同时打包JS bundle和鸿蒙工程,产物有一点大。我们后来做了按车型、城市拆包,把基础业务bundle和地图、支付等能力动态加载,启动速度才回到可接受范围。

配置过程中最容易忘的还有权限声明。鸿蒙端需要在模块配置文件里声明定位、网络、相机等权限,并且这些权限是运行时动态申请,不能只写在配置文件里。我们当时就是漏了权限申请,导致所有司机在鸿蒙手机上定位一直是0,匹配池直接空了,最后排查完发现是权限回调没处理。

5.2 原生模块桥接与权限适配

React Native自带的原生模块覆盖了常见的网络、存储、定位能力,但在鸿蒙端并不是所有API都能直接用。比如我们需要读取鸿蒙系统里的车辆硬件信息、申请后台定位权限,这些都需要自己封装桥接模块。我们写了一个自定义原生模块,通过Promise给JS层提供接口,鸿蒙端在收到请求后弹出权限弹窗,用户授权后再把结果回传给JS层。

// JS侧调用原生模块示例 import { NativeModules } from 'react-native'; const LocationBridge = NativeModules.OrderHarmonyLocationBridge; export async function getLocationWithAuth(): Promise<{ lat: number; lng: number }> { const location = await LocationBridge.getCurrentPosition(); if (!location.authorized) { throw new Error('LOCATION_PERMISSION_DENIED'); } return location.coords; }

这里有一个经验:桥接层一定要做“逻辑前置、结果纯化”。原生端只做能力调用,不要塞业务逻辑,返回给JS层的数据必须是纯数据对象,字段名和Android、iOS保持一致。我们之前把“是否授权”这个判断写在原生端,结果Android返回的字段名和鸿蒙不一样,JS层判断逻辑就得写两套,特别容易出Bug。后来统一成上面的结构,三端行为就一致了。

权限适配里还有一个冷启动问题。鸿蒙端某些机型对“后台连续定位”有省电策略,司机熄屏一段时间后定位会断开,导致订单位置不更新。我们在RN层做了心跳检测,每隔30秒获取一次定位并上传,如果连续3次拿不到定位,就主动弹窗让司机重新点亮屏幕。这个交互虽然有点打扰,但在货运场景里比“司机位置丢失导致订单无法指派”要可控得多。

5.3 性能优化与内存泄漏排查

跨平台应用最容易被吐槽的就是性能和内存。我们在鸿蒙端上线初期也遇到了两个问题:页面栈越堆越高导致内存暴涨,以及列表滚动卡顿。先看第一个问题。订单详情、司机列表、计价结果这些页面都是通过Navigation push进去的,如果用户一直点返回上一页,实际上页面实例并没有立即销毁,内存会持续增长。后来我们改用Navigation replace模式,在新页面打开时替换掉当前页面实例,减少栈的深度。对于需要保留的草稿状态,放到全局状态管理里,而不是依赖页面实例。

第二个问题是列表卡顿。司机列表和订单列表都是长列表,我们一开始直接在ScrollView里渲染几十个卡片,一到真实数据就卡。后来全部换成FlatList,并给每个卡片组件加上React.memo,把子组件里的临时箭头函数抽出来用useCallback包一层。这里有个小技巧:不要在render里直接写内联样式对象,比如style={{width: item.width}},每次渲染都会生成新对象,导致子组件重新渲染。我们把这些样式抽到组件外部或用StyleSheet.create缓存,列表的渲染帧率才上来。

内存泄漏排查方面,我们踩过一个比较深的坑:在useEffect里监听原生定位事件,但忘记在卸载时移除监听。因为RN容器在鸿蒙端的事件监听是全局的,页面卸载后监听器还挂在原生模块上,定位事件一回来JS层就报错说调用了已卸载组件的setState。后来我们统一封装了一个useNativeEventListener Hook,在卸载时自动移除所有原生监听器,类似的问题基本绝迹。

6. 常见问题排查与优化建议

6.1 问题速查表

跨平台联调阶段我们遇到不少奇奇怪怪的问题,这里整理一份速查表,直接按现象查原因,能省很多排查时间。

现象可能原因处理办法
订单状态从配送中跳回已接单本地状态版本号落后于服务端,旧推送覆盖新状态接入statusVersion,版本号小于本地则丢弃,并主动拉取服务端快照
重量计价多出0.00000001分JS浮点数运算精度问题金额统一用Math.round(total * 100) / 100,核心计费以服务端为准
重量正好50kg时价格不对边界值判断错误,区间定义不一致统一左闭右开,即≥min且<max,并写边界单测
司机列表为空候选筛选条件过严,或者司机定位数据为0先放宽车型兼容和搜索半径,再检查定位权限与上传链路
鸿蒙端定位一直为0权限未动态申请,或者回调未处理检查鸿蒙配置文件权限声明,以及桥接回调是否成功回传
长列表滚动明显卡顿使用了ScrollView渲染大量子项,组件未缓存改FlatList,抽离内联样式,对卡片组件包React.memo
页面栈内存持续增长Navigation push过多,页面实例未销毁使用replace代替push,释放不需要保留的页面实例
多点几次按钮导致状态异常动作重复触发,但缺少幂等处理在reducer层对同一动作做节流,重复事件直接忽略

这张表目前放在我们的Wiki里,新人接手时照着排查能少走一半弯路。其实很多问题并不是跨平台框架造成的,而是业务状态设计本身没做好,先把状态机和数据版本搞清晰,九成的问题自然就消失了。

6.2 后续优化方向

目前这套系统稳定跑了两个月,但我心里清楚还有不少可以优化的地方。一个是计价策略现在只按重量分档,后续可以考虑加入体积、距离、楼层搬运费这些因子,做一个更细粒度的动态计价引擎。另一个是匹配排序现在用的还是离线特征加简单规则,后续打算把司机实时位置、历史接单偏好、时段运力热力图拼起来,做一个基于评分的轻量推荐模型。

这里想多说一句:任何智能匹配策略都要先跑小流量实验再全量放量。我们当时改排序权重时,直接在全部订单上生效,结果某区域因为距离权重调低了,司机跑远路的时间变长,投诉率瞬间上升。后来所有排序参数都做成动态配置,配合A/B实验平台灰度切换,每个参数都要观察至少一周的接单率、取消率、完成时长再决定是否全量。这套流程比具体算法更重要。

然后是鸿蒙端适配的体验细节,后续想进一步优化冷启动动加载速度。现在离线bundle虽然稳定,但首包体积还是偏大,按城市、车型、用户身份做分包加载是一个明确方向。我也在观望鸿蒙原生生态的成熟度,等更多原生组件可用之后,可以逐步把地图、AR测量这类复杂页面从RN下沉到原生实现,在性能和开发效率之间找一个更好的平衡点。

最后分享一点个人体会

做这种跨平台项目,最大的收获不是跑通了多少功能,而是学会了“把核心逻辑从界面上剥离开”。订单状态机、计价函数、司机评分这三块业务,最终都抽成了平台无关的纯TypeScript模块,不依赖任何React Native的API。这也意味着未来就算某一天不用RN了,这些核心逻辑也能原封不动迁移到其他框架里。实际工作中,这比具体技术栈选什么更重要。

最后再分享一个小技巧:给所有订单状态、计费规则、匹配因子都加上可观测的日志输出。我们会在状态机每次迁移时打一条日志,包含订单ID、旧状态、动作、新状态、耗时。线上出问题时,靠这些日志能非常快地还原订单到底经历了什么,而不是靠客服截图猜来猜去。这个习惯帮我们省了无数晚睡得充实,值得一试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进