React Native 蓝牙打印机实战:从“页面级连接”到“全局 Context 自动托管”的架构演进

19 阅读5分钟

React Native 蓝牙打印机实战:从“页面级连接”到“全局 Context 自动托管”的架构演进

在开发 React Native 应用对接蓝牙硬件(如打印机)时,很多开发者初期都会遇到一个典型问题:连接逻辑写在哪里最合适?

本文记录了一次真实的架构重构过程:从最初的“页面级手动连接”,踩坑“App.js 阻塞卡顿”,最终演进为“Context 全局自动托管”。

🛑 第一阶段:页面级连接(刀耕火种)

最初的实现思路非常直观:哪个页面要打印,就在哪个页面连接。

代码实现

Reception.js(收货页面)中:

// Reception.js
useEffect(() => {
  initPrinter(); // 进页面就初始化
}, []);

const initPrinter = async () => {
  // 1. 检查连接
  // 2. 没连就去连
  // 3. 连不上报错
};

遇到的痛点

  1. 重复连接:用户从“收货页”跳转到“发货页”,打印机需要断开重连,或者需要重新检查状态,浪费资源。
  2. 体验割裂:每次进入业务页面都要转圈圈等待连接,用户体验极差。
  3. 状态丢失:页面销毁后,连接状态和监听器也随之销毁。如果打印机意外断开,App 只有在下次打印时才会发现。

🚧 第二阶段:App.js 强行挂载(性能陷阱)

为了解决“重复连接”的问题,我们尝试把逻辑上移到根组件 App.js

代码实现

// App.js
useEffect(() => {
  connectToLastDevice(); // App 启动就连接
}, []);

遇到的痛点

  1. UI 卡顿App.js 是应用的根基。蓝牙扫描、连接、读取本地存储这些异步操作,混合在根组件的渲染周期里,极易导致 App 启动时掉帧、卡死(JS 线程阻塞)。
  2. 状态传递地狱:虽然 App 连上了,但深层页面(如 Detail.js)怎么知道连没连上?只能通过 Props 一层层透传,或者依赖全局变量(不仅丑陋而且不安全)。

✅ 第三阶段:Context 全局托管(终极方案)

最终,我们引入了 React 的 Context API,将打印机服务封装成一个独立的“黑盒”,实现了逻辑与 UI 的完全解耦

核心架构图

graph TD
  A[App Root] --> B[PrinterProvider]
  B --> C[AuthProvider]
  C --> D[NavigationContainer]
  D --> E[Page A]
  D --> F[Page B]
  
  B -.->|提供全局连接状态| E
  B -.->|提供打印方法| F

关键实现步骤

1. 创建 PrinterContext

建立一个独立的上下文环境,专门管理打印机的一切。

// src/context/PrinterContext.js
export const PrinterProvider = ({ children }) => {
  // 状态只在这里维护,不污染 App.js
  const [isConnected, setIsConnected] = useState(false);
  const isConnectedRef = useRef(false); // 解决闭包/竞态问题的关键

  // 初始化:只运行一次
  useEffect(() => {
    // 1. 启动蓝牙事件监听
    const listener = NativeEventEmitter.addListener('onStatusChange', handleStatus);
  
    // 2. 自动尝试重连上次设备
    autoConnectToLastDevice();

    return () => listener.remove();
  }, []);

  return (
    <PrinterContext.Provider value={{ isConnected, printText }}>
      {children}
    </PrinterContext.Provider>
  );
};
2. 接入 App 根节点

App.js 中包裹 Provider。注意它的位置:包裹在业务逻辑之外

// src/App.js
export default function App() {
  return (
    <PrinterProvider>  {/* 👈 只要这一行,服务就启动了 */}
      <AuthContext.Provider>
        <AppContainer />
      </AuthContext.Provider>
    </PrinterProvider>
  );
}
3. 业务页面“零负担”使用

业务页面不再需要关心连接逻辑,直接“拿来即用”。

// src/hooks/usePrinter.js
export const usePrinter = () => useContext(PrinterContext);

// 任意业务页面.js
const { isConnected, printText } = usePrinter();

const handlePrint = () => {
  if (!isConnected) {
    alert('请先连接'); // 状态是全局实时的
    return;
  }
  printText('Hello World');
};

🏗️ Provider 放置策略(关键知识点)

在重构过程中,我们将 PrinterProvider 放置在了 App.js 的最外层,这个位置的选择是有深刻讲究的。

// src/App.js
<PrinterProvider> {/* 👈 放在最外层 */}
  <AuthContext.Provider>
    <Provider>
      {/* ... */}
    </Provider>
  </AuthContext.Provider>
</PrinterProvider>

为什么放在最外层?

  1. 独立性(Independence)

    • 蓝牙硬件连接属于“基础设施服务”,它通常不依赖于用户身份
    • 即使放在 AuthContext 外部,无论当前用户是谁(甚至未登录状态),后台的蓝牙连接都可以保持活跃。
    • 反面教材:如果放在 AuthContext 内部,当用户“退出登录”时,PrinterProvider 会被卸载,导致连接断开;再次登录时又要重新连接,体验中断。
  2. 生命周期(Lifecycle)

    • 最外层的 Provider 拥有全应用级生命周期(App Lifecycle)。
    • 它的存活时间 = App 运行时间。这意味着只要 App 不被杀死,连接就永远不会因为页面跳转、路由重置或用户登出而意外断开。
  3. 性能(Performance)

    • 顶层组件通常是静态的,Props 变化极少。
    • 放在最外层可以避免因为中间层(如 NavigationContainerAuthProvider)的状态变化而导致 Provider 意外卸载或重渲染。

🌟 方案优势总结

  1. 无感连接(Silent Connection)

    • 用户在登录页输入密码时,后台就已经悄悄连上打印机了。
    • 进入业务页面时,设备已经是 Ready 状态,操作零等待
  2. 性能优化(Performance)

    • 连接状态的变化只会触发使用了 usePrinter 的组件重渲染,不会导致整个 App 树重绘
    • 解决了 App.js 阻塞导致的启动卡顿问题。
  3. 状态保活(Keep-Alive)

    • 无论路由如何跳转,Context 始终存在。
    • 全局单例维护连接,避免了重复创建销毁连接的资源消耗。
  4. 竞态条件处理(Race Condition)

    • 我们在重构中还发现了一个经典 Bug:蓝牙底层已连接,但 JS 层因为超时报错。
    • 解法:引入 useRef 追踪真实状态,在 catch 块中二次确认状态,成功解决了“假报错”问题。

💡 给开发者的建议

对于蓝牙打印机、WebSocket 连接、全局定位这类**“应用级服务”**,不要犹豫,直接上 Context。不要试图在页面组件或根组件里手动管理它们的生命周期,将它们抽离成独立的 Provider 是 React 架构的最佳实践。