把平台判断从业务代码里清出去:给 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:按语义拆分接口。比如把「存储敏感数据」拆成 SecureStorageBiometricAuth 两个独立接口,各自有清晰的职责边界。我倾向于策略 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 对象在入口处创建所有服务实例,然后一路传下去,也工作得很好。核心是「业务代码不自己创建依赖,而是接受注入」,至于用容器还是手工传参,是次要问题。