把平台判断从业务代码里清出去:给 Native 能力加一层可替换的注入接口
我们写跨端代码时,经常会碰到这种场景:需要调用某个平台特有的能力,比如获取设备 ID、请求相册权限、调用扫码。最直觉的做法是写一堆 if-else 判断平台,然后分别调用对应 API。但这条路走到后面会变成灾难——业务逻辑里散落着各种平台判断,新人接手时根本搞不清楚哪些地方做了特殊处理,改一个逻辑要在三四个平台文件里来回跳。
核心方案很清楚:把所有平台相关的 Native 能力抽象成一组接口,通过依赖注入将具体实现注入到业务层,业务代码只依赖接口,永远不出现 platform === 'ios' 这样的判断。
为什么直接写平台判断是技术债
我先描述一个真实场景。2021 年我接手过一个 React Native 项目,代码库里充斥着这样的逻辑:
if (Platform.OS === 'ios') {
const result = await NativeModules.IOSBiometric.authenticate();
} else if (Platform.OS === 'android') {
const result = await NativeModules.AndroidFingerprint.authenticate();
} else {
// web fallback
}
这段代码出现在 7 个不同的文件里。后来产品要求加上面部识别的错误处理,Android 端指纹识别 API 从 FingerprintManager 迁移到 BiometricPrompt(Android 9+ 废弃旧 API),我不得不逐个文件去改,有两个地方的处理逻辑还写得不一致,导致 Android 10 上行为异常,花了整整一天排查。
这类问题的根源不是开发者的疏忽,而是架构上的缺陷:平台判断语句直接嵌入了业务流,导致跨平台差异的修改成本随代码规模线性增长。 假设你有 N 个业务模块、M 个平台,最坏情况下你需要维护 N × M 份平台相关代码路径。当 M 变成 3(iOS、Android、Web)甚至更多(Windows、macOS、小程序)时,这个组合爆炸会让任何需求变更都变成一场噩梦。
更隐蔽的问题是,这种写法让你无法单独测试某个平台的逻辑。你想验证 Android 端的新生物识别流程是否正确工作?抱歉,你得在真机或模拟器上跑整个业务流程,因为平台判断和业务代码是耦合的。
接口抽象:把「是什么平台」变成「我要什么能力」
解决思路是把问题翻转过来。不要问「当前是什么平台」,而是声明「我需要一个生物识别能力」。然后由外部注入这个能力的平台实现。
先定义接口:
// native/BiometricService.ts
export interface BiometricService {
/** 发起生物识别验证,返回是否通过 */
authenticate(reason: string): Promise<BiometricResult>;
/** 查询设备是否支持生物识别 */
isAvailable(): Promise<boolean>;
}
export type BiometricResult =
| { status: 'success' }
| { status: 'failed'; error: string }
| { status: 'unavailable'; reason: string };
接口只描述能力和契约,不涉及任何平台细节。业务代码依赖这个接口:
// features/PaymentScreen.ts
class PaymentViewModel {
constructor(
private biometricService: BiometricService,
private paymentApi: PaymentApi
) {}
async confirmPayment(amount: number) {
const available = await this.biometricService.isAvailable();
if (!available) {
throw new Error('Biometric not available');
}
const result = await this.biometricService.authenticate('确认支付');
if (result.status === 'success') {
return this.paymentApi.execute(amount);
}
}
}
这里完全没有 Platform.OS 的身影。PaymentViewModel 只知道「我有一个能做生物识别的东西」,至于是用 Face ID 还是指纹,是 iOS 的 LocalAuthentication 还是 Android 的 BiometricPrompt,它一概不关心。
平台实现放在独立文件里:
// native/ios/BiometricService.ios.ts
import LocalAuthentication from 'react-native-local-authentication';
export class IOSBiometricService implements BiometricService {
async authenticate(reason: string): Promise<BiometricResult> {
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: reason,
});
return result.success
? { status: 'success' }
: { status: 'failed', error: 'User cancelled or failed' };
} catch (e) {
return { status: 'unavailable', reason: e.message };
}
}
async isAvailable(): Promise<boolean> {
const result = await LocalAuthentication.hasHardwareAsync();
return result && await LocalAuthentication.isEnrolledAsync();
}
}
// native/android/BiometricService.android.ts
import BiometricPrompt from 'react-native-biometrics';
export class AndroidBiometricService implements BiometricService {
async authenticate(reason: string): Promise<BiometricResult> {
// Android 端的具体实现
}
// ...
}
关键一步是注入。在应用入口处,根据平台创建对应的实现并注入到依赖容器:
// di/container.ts
import { Platform } from 'react-native';
const container = new DIContainer();
container.register<BiometricService>('BiometricService', {
useFactory: () => Platform.select({
ios: () => new IOSBiometricService(),
android: () => new AndroidBiometricService(),
default: () => new StubBiometricService(),
}),
});
注意:平台判断只出现在这一个地方——依赖注入的注册代码里。这本质上是一个工厂方法,它的职责就是「我知道不同平台有不同的实现,我来负责把正确的那个挑出来」。业务代码永远看不到这段逻辑。
多平台场景下的注入策略
接口抽象说起来简单,但在实际工程中会遇到几个棘手的问题。
第一个问题是 API 不对齐。 不同平台能提供的能力粒度不一样。iOS 的 Keychain 支持生物识别保护模式,Android 的 Keystore 也有类似能力,但 API 参数和错误码完全不同。这时候接口设计有两种策略:
策略 A:取并集,定义最大的接口,不支持的平台返回「不支持」状态。这样接口统一,但接口定义会膨胀。
策略 B:按语义拆分接口。比如把「存储敏感数据」拆成 SecureStorage 和 BiometricAuth 两个独立接口,各自有清晰的职责边界。我倾向于策略 B,因为它遵守接口隔离原则——调用方不需要的东西不应该出现在接口里。
第二个问题是编译时安全 vs 运行时注入。 TypeScript 项目通常用路径后缀(.ios.ts / .android.ts)让 Metro bundler 在编译时选择文件。这本质上也是一种平台判断,只不过从运行时移到了编译时。如果你想在同一个构建产物里动态切换实现(比如在 Web 端根据浏览器能力选择不同的 API),编译时分发就不够用了,需要运行时注入。
我的建议是:即使你用编译时分发,也保留注入的架构。编译时分发只是注入的一种实现方式——bundler 帮你做了「选择哪个文件」这件事。当你需要切换到运行时注入时(比如为了可测试性),架构不用变,只需要改变注入方式即可。
第三个问题是能力降级。 微信小程序、Web 端可能根本没有生物识别 API。这时候需要注入一个降级实现:
export class FallbackBiometricService implements BiometricService {
async authenticate(): Promise<BiometricResult> {
return { status: 'unavailable', reason: 'Platform not supported' };
}
async isAvailable(): Promise<boolean> {
return false;
}
}
业务代码通过检查返回值来决定后续流程——是引导用户用密码支付,还是直接跳过生物识别环节。降级逻辑是显式的、可测试的,而不是藏在某个 else 分支里的静默跳过。
测试的收益才是最被低估的
注入接口最大的好处不是代码整洁,而是可测试性。你可以为业务逻辑编写纯粹的单元测试,注入 mock 实现,模拟各种平台行为:
describe('PaymentViewModel', () => {
it('should proceed payment on biometric success', async () => {
const mockBiometric: BiometricService = {
authenticate: jest.fn().mockResolvedValue({ status: 'success' }),
isAvailable: jest.fn().mockResolvedValue(true),
};
const mockPayment = { execute: jest.fn() };
const vm = new PaymentViewModel(mockBiometric, mockPayment);
await vm.confirmPayment(100);
expect(mockPayment.execute).toHaveBeenCalledWith(100);
});
it('should throw when biometric is unavailable', async () => {
const mockBiometric: BiometricService = {
authenticate: jest.fn(),
isAvailable: jest.fn().mockResolvedValue(false),
};
const vm = new PaymentViewModel(mockBiometric, mockPayment);
await expect(vm.confirmPayment(100)).rejects.toThrow();
});
});
这些测试不依赖任何平台,不需要启动模拟器,运行速度是毫秒级的。你可以轻松覆盖「用户取消」「硬件不支持」「系统错误」等各种边界情况,而不用在真机上反复操作。
回想一下最初那个改 7 个文件的例子。如果当时有这套架构,Android 端 BiometricPrompt 的迁移只会影响 AndroidBiometricService 这一个文件,修改后跑一遍单元测试就能确认业务逻辑没有被破坏。一天的排查时间可以压缩到一小时以内。
常见问题
这样不会过度设计吗?小项目值得吗?
如果项目只有两个平台、三五个 Native 调用,直接写平台判断确实更快。但你需要意识到这是一笔技术债,设一个阈值:当 Native 调用超过 5 个、或者同一个能力在 2 个以上文件中被调用时,就应该抽接口。这个阈值不高,大多数生产级项目会在前两个迭代就达到。
接口定义太理想化,实际平台 API 差异很大怎么办?
接口不是要消除差异,而是把差异封装在实现内部。如果两个平台的 API 模型完全不同(比如一个用回调,一个用 Promise),接口统一用 Promise,实现内部做适配。如果能力粒度不同(比如一个支持「扫描二维码」,一个只支持「扫描任意码」),可以拆成两个接口,或者通过参数控制。关键是业务代码不应该感知这些差异。
依赖注入容器本身不会引入复杂度吗?
不一定要用 DI 容器。最简单的注入方式是构造函数传参——就像上面的 PaymentViewModel 例子。当你需要更灵活的生命周期管理时才引入容器。我见过不少项目用一个手动组装的 ServiceLocator 对象在入口处创建所有服务实例,然后一路传下去,也工作得很好。核心是「业务代码不自己创建依赖,而是接受注入」,至于用容器还是手工传参,是次要问题。