iOS 统计页把三个相同权重分别显示成 33%,合计却只有 99%。把差额全部塞给最后一项,又可能让零权重项目获得百分比。在云端 Mac 的构建流水线里,这类问题不需要启动模拟器:把分配算法提取成纯 Swift 函数,就能检查总和、排序和异常输入,再让界面消费同一份结果。
先定义显示单位与输入契约
这里处理非负整数计数,例如三个分类的完成任务数,而不是金额、浮点测量值或已经舍入的百分比。设显示总单位为 100,每个单位代表一个百分点;需要一位小数时改为 1000,每个单位代表零点一个百分点。
契约包含四条:输出长度与输入相同;输出非负且合计等于总单位;零权重获得零单位;相同输入始终返回相同结果。空数组、负权重、总权重为零直接失败,不擅自生成平均分配。
“没有数据”和“每项占比相同”是两种业务状态。全零输入应由页面显示空态,不能通过分配算法伪装成有效统计。
同余数时按输入下标决定先后,因此调用方必须先按稳定的分类标识排列数据。不要把字典遍历顺序当作产品规则,否则刷新页面可能改变获得补偿单位的项目。
用整数最大余数法补齐差额
先计算每项权重乘以总单位,再除以总权重。商是基础配额,余数用来排列补偿顺序。基础配额合计不足的部分,按余数从大到小逐项增加一个单位。
因为每项都向下取整,待补单位数严格小于项目数。没有必要循环到数值收敛,也不需要浮点容差。把下面代码保存为 percentage-check.swift,它同时包含实现和最小检查。
enum AllocationError: Error {
case invalidInput
case overflow
}
func allocate(_ weights: [Int], units: Int = 100) throws -> [Int] {
guard !weights.isEmpty,
(1...10_000).contains(units),
weights.allSatisfy({ $0 >= 0 }) else {
throw AllocationError.invalidInput
}
var total = 0
for weight in weights {
let sum = total.addingReportingOverflow(weight)
guard !sum.overflow else {
throw AllocationError.overflow
}
total = sum.partialValue
}
guard total > 0 else {
throw AllocationError.invalidInput
}
var result = [Int]()
var remainders = [Int]()
for weight in weights {
let product = weight.multipliedReportingOverflow(by: units)
guard !product.overflow else {
throw AllocationError.overflow
}
result.append(product.partialValue / total)
remainders.append(product.partialValue % total)
}
let remaining = units - result.reduce(0, +)
let order = weights.indices.sorted {
if remainders[$0] == remainders[$1] {
return $0 < $1
}
return remainders[$0] > remainders[$1]
}
for index in order.prefix(remaining) {
result[index] += 1
}
return result
}
func expectFailure(_ weights: [Int], units: Int = 100) {
do {
_ = try allocate(weights, units: units)
} catch {
return
}
preconditionFailure("Expected allocation to fail")
}
let equal = try allocate([1, 1, 1])
precondition(equal == [34, 33, 33])
let withZero = try allocate([0, 1, 1])
precondition(withZero == [0, 50, 50])
let tenths = try allocate([1, 1, 1], units: 1000)
precondition(tenths == [334, 333, 333])
for a in 0...12 {
for b in 0...12 {
for c in 0...12 {
let weights = [a, b, c]
let total = weights.reduce(0, +)
if total == 0 { continue }
let output = try allocate(weights)
precondition(output.count == weights.count)
precondition(output.reduce(0, +) == 100)
precondition(output.allSatisfy { $0 >= 0 })
for index in weights.indices {
let floorQuota = weights[index] * 100 / total
precondition(
output[index] == floorQuota ||
output[index] == floorQuota + 1
)
if weights[index] == 0 {
precondition(output[index] == 0)
}
}
}
}
}
expectFailure([])
expectFailure([0, 0])
expectFailure([-1, 2])
expectFailure([1], units: 0)
expectFailure([Int.max, 1])
expectFailure([Int.max])
代码刻意在求和、乘法两处检查溢出。即使最终百分比很小,中间的权重乘积仍可能超出整数范围。这里选择拒绝输入,不用转换成浮点数掩盖精度变化;如需接受更大计数,应另行设计宽整数运算。
在云端 Mac 执行独立验收
先用 xcrun swift --version 确认当前工具链,再执行以下命令。检查成功时没有输出,断言或未处理错误会让进程失败,流水线必须保留该退出状态。
set -eu
xcrun swift percentage-check.swift
用于正式门禁时,可以用 xcrun swiftc -Onone 编译后运行,不能使用会关闭这些断言的优化模式。更稳妥的长期做法,是将函数放进业务模块,把断言迁入测试目标,再由现有测试命令执行,避免脚本与产品各维护一份算法。
这里枚举了三个权重的小范围组合,不是性能跑分。它覆盖全零附近、单项独占、余数相同与小权重被舍为零等结构。追加测试时应扩大输入形态,而不是单纯重复同一个均匀样本。
区分舍入误差与业务规则
最容易漏掉的是排序后失去身份映射。实现只排序下标,最终数组仍对应原始项目;界面不能直接拿余数排名作为分类显示顺序。
其次,不要要求“每个正权重都至少显示一个单位”。当分类数超过显示单位数时,这个要求与总和守恒无法同时满足。需要展示极小占比时,可以增加显示精度,或者另设“低于显示阈值”的文案,但不能暗中修改配额。
最后,不要把此方法当作跨数据集的单调性保证。调整其他分类的权重或显示精度后,余数排名可能变化。测试应验证当前数据的配额范围,而不是断言某项跨刷新永远不减少。
小数格式化只负责展示
总单位为 1000 时,334 表示 33.4%。如果格式化接口接收零到一之间的比例,应传入 334 / 1000 对应的数值,而不是再乘一百。格式化阶段不能逐项重新计算原始权重,也不能二次取整成另一套精度。
把失败收敛成可检查的证据
合并前保留输入权重、总单位、基础配额、余数排名和最终输出。生产统计可能包含敏感分类时,使用固定夹具重现,不把真实业务明细写入构建产物。
评审重点是三个边界:非法输入能否明确失败;相同余数能否稳定裁决;界面格式化是否保持既定精度。验收通过后,数据层只提供权重,分配层只提供整数单位,展示层只负责本地化格式。三层职责分开,百分比合计异常就能定位到具体环节,而不是靠最后一项补差维持表面正确。
常见问题
为什么不能对每项百分比单独四舍五入?
逐项舍入不能保证总和守恒。例如三个相同权重各显示为百分之三十三,合计只有百分之九十九。最大余数法先分配整数配额,再按余数补齐剩余单位。
最大余数法能保证每项百分比随权重增加而增加吗?
不能把它当作跨数据集的单调性保证。其他权重或显示单位变化时,余数排名可能改变。本文保证当前输入的总和、配额边界与确定性,不保证跨输入的显示结果单调。