做Flutter的人,多少都遇到过这种尴尬:跨端框架玩得飞起,一碰到系统级能力就拉了胯。我在给OpenHarmony做移动数据使用监管助手App的时候,第一个硬骨头就是SIM卡管理。说它硬,倒不是说接口有多难调,而是这个模块夹在Flutter跨端层和OpenHarmony原生系统能力中间,两头都得伺候好:Flutter层要拿SIM卡信息、要监听卡状态变化,原生层要处理权限、多卡、状态机这一堆破事。这篇实战记录我就把整个SIM卡管理模块从设计到落地的过程完整过一遍,包括权限模型、MethodChannel/EventChannel双通道、双卡处理,以及我在真机上踩过的那些坑,适合已经在OpenHarmony上用Flutter做过基本页面、但还没碰过系统通道的开发者直接参考。
1. 移动数据监管App的整体设计与SIM卡模块定位
1.1 为什么这App一定要跨端:业务落地和分发成本
先交代一下背景。这个App的核心业务是给家长、企业设备管理员提供一个“移动数据使用监管”工具,典型的使用场景是:孩子拿手机乱刷视频超流量了,管理员希望在后台一目了然看到每个卡槽的运营商、卡状态、默认数据通道,并且在拔卡、换卡、切换数据卡的时候立即收到提醒。业务本身不复杂,但有一个很现实的问题:目标设备是碎片化的。OpenHarmony生态设备来自不同厂商,系统版本、屏幕尺寸、预装能力都不一样,如果全用原生开发,每个厂商适配一次,成本直接爆炸。
Flutter在这里的优势就体现出来了:UI层一次编写,跑在OpenHarmony标准设备上,业务快速迭代不受系统版本牵制,流量图表、规则配置、提醒页面这些高频变化的部分全部走Flutter侧。系统能力层(Telephony、SIM卡、网络状态)则走原生通道。这套组合的另一层考虑是团队人力:我们团队Flutter人数占大头,原生侧只需要一个人维护系统插件,两边解耦后维护压力小很多。
1.2 SIM卡管理的四件事:身份、状态、数据通道、事件上报
我把SIM卡管理拆成了四个明确的子任务,每个子任务对应一套接口和一套数据模型,这样开发的时候思路清晰,后面出了问题也容易定位。
第一是身份识别。一张SIM卡插进设备,App要能判断运营商是谁、卡的编号是什么、当前插在哪个卡槽。这是换卡检测和流量归属判断的基础。
第二是状态判断。OpenHarmony的SIM卡状态机比Android还要抽象一点,常见的有未插卡、已锁定、就绪、未知等几种状态。监管工具最关心的是“就绪”状态——只有就绪了,数据通道才可能可用。
第三是数据通道关联。双卡设备上,监管用户必须知道当前默认走流量的是卡0还是卡1,否则流量统计全乱套。这个能力OpenHarmony原生提供了默认数据卡查询接口,我需要通过通道暴露给Flutter层。
第四是事件上报。卡被拔出、卡被锁定、默认数据卡切换,这些属于“持续变化的信息”,单独用查询接口只能拿到那一刻的快照,必须在原生侧挂监听,把变化实时推给Flutter侧UI。这里就决定了通信方案不能只靠MethodChannel,必须引入EventChannel。
这四个子任务合起来,本质上回答了一个问题:这台设备现在“以什么样的身份在消耗移动数据”。SIM卡管理是整条监管链路的数据源头,源头不准,后面流量统计、规则判断全都会跟着错。
2. 原生侧准备:Telephony能力与权限模型
2.1 OpenHarmony的权限分级与受限字段
OpenHarmony的权限体系跟Android有明显区别,它把权限按敏感程度分成normal、system_basic、system_core三个级别。SIM卡相关的能力大部分落在normal和system_basic这两个级别上。
我在项目里实际用到的权限是ohos.permission.GET_TELEPHONY_STATE,这个权限本身是normal级,MDN上有明确说明,普通应用通过requestPermissionsFromUser就能拉起来弹窗授权。但这里有个大坑:权限能申请到不代表所有字段都能读。IMSI、ICCID这两个字段在OpenHarmony里属于受限数据,即使你申请了GET_TELEPHONY_STATE,普通应用调用接口也拿不到真实值,返回的是空字符串或者脱敏后的结果。
要拿受限字段,现实路径有两条。一条是走ACL(Access Control List)方式,在应用市场提交审核时申请受限权限白名单,审核通过后系统才放开读取。另一条是应用直接被预装为系统应用或使用高等级签名证书安装,这通常是企业设备管理的做法。我做监管App时选了后一条:客户本身就是设备管理方案商,设备侧有定制的系统应用白名单,开发阶段用调试证书签个系统应用级别包,就能全字段读取。
这里要提醒一句:如果是独立上架到应用市场的普通App,别指望默认能拿到IMSI和ICCID。所以业务设计上必须做兼容——拿不到受限字段时,至少保住运营商名称、卡状态、默认数据卡这些基础能力,不然应用在真机上就是一堆空数据,用户一头雾水。
权限配置上,我在module.json5里做了两处。第一处是requestPermissions数组里声明ohos.permission.GET_TELEPHONY_STATE和ohos.permission.GET_NETWORK_INFO,第二处是系统应用场景下额外在签名文件中配置对应的ACL权限。这里要注意权限声明顺序:如果App同时要读多个权限,建议把高频权限放在前面,OpenHarmony的授权弹窗是一个一个弹的,顺序会影响用户耐心。
2.2 SIM信息读取API与状态枚举
OpenHarmony的Telephony能力在API 9以后统一通过@kit.TelephonyKit引入,SIM卡相关的模块是@ohos.telephony.sim。我项目里用到的核心接口不多,列出来就这几个:
sim.getSIMAccountInfo(slotId):获取指定卡槽的SIM卡账户信息,返回对象里包含operatorName、operatorNumeric、iccid、imsi、mcc、mnc等字段。sim.getSimState(slotId):获取SIM卡状态,返回的是数字枚举。sim.on('simStateChange'):注册SIM卡状态变化监听,插拔卡、锁卡都会触发回调。sim.getDefaultDataSlotId():获取当前默认数据卡槽位ID。telephony.getNetworkState(slotId):获取指定卡槽的网络注册状态,用来判断当前卡是否真正注册上网络。
状态枚举这一块我吃过亏,OpenHarmony的SIM状态枚举值跟Android不完全一样,Android的SIM_STATE_ABSENT是1,OpenHarmony把“未插卡”定义为SIM_STATE_NOT_PRESENT,值也是1,但“已锁定”在OpenHarmony是SIM_STATE_LOCKED(值3),Android则是SIM_STATE_PIN_REQUIRED(值2)和SIM_STATE_PUK_REQUIRED(值3)分开的。跨端开发最容易在这里出事:Flutter层如果按Android的枚举逻辑判断,OpenHarmony上锁卡状态就匹配不上。
我在工程里维护了一张枚举映射表,原生侧把OpenHarmony的枚举值转换成语义字符串,Flutter侧只认字符串。这样上层UI不管底层是Android还是OpenHarmony,判断逻辑统一写成state == 'ready'这种可读形式。状态字符串我定义了五档:
| 原生返回值 | 转换后字符串 | 业务含义 |
|---|---|---|
| SIM_STATE_UNKNOWN | unknown | 状态未知 |
| SIM_STATE_NOT_PRESENT | not_present | 卡槽无卡 |
| SIM_STATE_LOCKED | locked | 卡被锁定 |
| SIM_STATE_READY | ready | 卡就绪可用 |
| SIM_STATE_DEACTIVATED | deactivated | 卡被停用 |
这套设计在后面做监管提醒时非常省事,UI层判断状态是否异常只需要查字符串,不需要关心平台差异。
3. Flutter与原生双向通信:通道设计
3.1 MethodChannel管查询、EventChannel管上报
Flutter在OpenHarmony上的系统通信机制,跟Android的Flutter插件体系是同一套设计思路,核心就是通道。我在SIM卡管理模块里用了两个通道:
MethodChannel管“一次性”的操作。比如Flutter侧调getSimAccountInfo、getSimState、getDefaultDataSlotId,属于请求-响应模式,调用一次拿一个结果,方法名在两端对齐,参数按约定传槽位编号。这个通道是实现成本最低的,两端各写一小段代码就能跑通。
EventChannel管“持续变化”的事件。SIM卡状态变化发生后,即使Flutter侧那一瞬间没有发起任何请求,原生侧也应该把变化推出来。EventChannel的工作方式就是原生侧维护一个事件流,Flutter侧订阅它,事件产生后直接推给Dart层。我在原生侧注册了sim.on('simStateChange')监听,收到回调就把最新的状态集合塞进事件流,Flutter侧一收到就刷新UI。
这里有个很容易被忽略的架构问题:MethodChannel和EventChannel虽然名字里有Channel,但它们之间是独立的。业务上经常需要“查询当前状态”和“订阅状态变化”配合使用,比如App刚启动时先MethodChannel拉一次全量快照,再EventChannel订阅增量变化。我在代码里把这套逻辑抽象成一个SimCardRepository,对UI层暴露的是“先快照、后订阅”的完整方法,UI层永远不直接碰Channel对象。
3.2 数据模型与序列化约定
跨端通信最容易出现翻车的点,是对不上数据格式。MethodChannel和EventChannel在OpenHarmony与Flutter之间传输数据,默认走的是标准JsonValue编码,说白了就是把数据序列化成一个可以直接转JSON的结构。IMSI、ICCID这种字符串没问题,但如果是复杂的嵌套对象,就得注意平台差异。
我在两端的接口定义里统一了一个最小数据模型,SIM卡账户信息序列化后包含这些字段:
{ "slotId": 0, "isPresent": true, "state": "ready", "operatorName": "中国移动", "operatorNumeric": "46000", "imsi": "46000**********", "iccid": "898600****", "mcc": "460", "mnc": "00", "isDefaultDataSlot": true }这个模型的确定过程比较讲究。第一版我直接把原生返回的所有字段一股脑映射到Dart类里,结果没几个真能拿到值(受限字段返回空),后面代码里到处都是判空,越写越难看。后来我干脆先做减法:只要业务真正要用的字段。字段越少,序列化越稳定,排查问题也越容易。
在Dart侧,我用了fromJson静态方法做反序列化,里面统一做类型转换。因为MethodChannel返回的Map里,数字可能被解析成num类型,直接赋值给int会有运行时类型错误。我的习惯是写一个safeParseInt工具函数,把所有数字字段走一遍,解析失败就给默认值,这样至少UI层不会因为一个字段解析失败直接炸掉。
4. 从工程搭建到双通道跑通:完整实操
4.1 插件工程结构与注册
开始动手之前先讲一下工程怎么组织。我的做法是单独建一个Flutter插件工程,不把原生代码塞进主App工程里。插件工程的好处是职责边界清晰:Flutter侧是通用业务逻辑,OpenHarmony原生侧只在插件里出现,以后要适配别的设备或别的平台,把插件抽出来就能复用。
OpenHarmony侧的插件实现是放在entry/src/main/ets/plugin目录下的,实现类需要继承Flutter引擎提供的插件基类,在onAttach生命周期里创建MethodChannel和EventChannel。我用的是社区维护的Flutter for OpenHarmony embedding方案,插件注册方式跟Android的PluginRegistry思路类似,在entry模块的初始化阶段调用注册函数把插件挂到引擎上。
一个容易踩的坑是插件重复注册。在OpenHarmony上,如果Flutter引擎被多次初始化(比如页面热重启),插件类可能会触发多次onAttach,每次都new一个Channel出来,导致事件订阅重复或MethodChannel回调被旧实例拦截。我在插件的onDetach里做了清理,同时用一个插件级单例管理Channel实例,实测下来重复初始化问题就消失了。
4.2 查询链路实现:Flutter调一次原生返回全量数据
查询链路的目标很简单:Flutter侧传入slotId,原生侧返回SIM卡账户信息。原生侧的方法调用处理器代码长这样:
// OpenHarmony侧 import { MethodChannel, EventChannel } from '@ohos/flutter_ohos'; import { sim } from '@kit.TelephonyKit'; private async handleGetSimAccountInfo(call: MethodCall): Promise<object> { const slotId = call.argument('slotId') as number; let result: Record<string, Object> = {}; try { const state = await sim.getSimState(slotId); result['slotId'] = slotId; result['state'] = this.mapSimState(state); result['isPresent'] = state !== sim.SimState.SIM_STATE_NOT_PRESENT; if (result['isPresent']) { const info = await sim.getSIMAccountInfo(slotId); result['operatorName'] = info.operatorName; result['operatorNumeric'] = info.operatorNumeric; result['imsi'] = info.imsi; result['iccid'] = info.iccid; result['mcc'] = info.mcc; result['mnc'] = info.mnc; } const defaultSlotId = await sim.getDefaultDataSlotId(); result['isDefaultDataSlot'] = defaultSlotId === slotId; } catch (error) { // 兜底返回错误信息,由Flutter侧统一封装为异常 } return result; }这段代码的逻辑顺序是有讲究的。我先查SIM状态,如果卡槽根本没卡,后面那些getSIMAccountInfo就没必要调了,因为没插卡时调用会抛异常。拿默认数据卡这件事比较关键,监管App要判断当前流量的归属,所以每一张卡的信息里都要标注“它是不是默认数据卡”。
Flutter侧的查询封装也不复杂,我把MethodChannel的方法名映射成Dart方法:
class SimManagerChannel { static const MethodChannel _methodChannel = MethodChannel('sim_manager/method'); Future<SimAccountInfo?> getSimAccountInfo(int slotId) async { try { final Map<Object?, Object?>? result = await _methodChannel .invokeMapMethod('getSimAccountInfo', {'slotId': slotId}); if (result == null) return null; return SimAccountInfo.fromJson(Map<String, dynamic>.from(result)); } on PlatformException catch (e) { debugPrint('query sim info failed: ${e.message}'); return null; } } }这里有个细节:MethodChannel的invokeMethod在OpenHarmony上的返回类型可能是基础Map,但Dart侧要拿到默认值是Map<Object?, Object?>,并不能直接转成Map<String, dynamic>。所以我在封装层做了一次显式类型转换,顺便把PlatformException的异常吞掉并打日志。这样UI层拿到的永远是有效对象,如果拿不到就返回null,上层再做一次兜底提示。
4.3 状态监听链路实现:EventChannel把变化推给UI
查询链路解决了“当下什么状态”,监听链路解决“状态变了怎么办”。原生侧EventChannel的实现需要处理两种角色的生命周期:一个是事件源的监听,一个是Flutter侧的订阅。
我的做法是:在EventChannel的setStreamResult回调里注册和反注册simStateChange监听。onStreamStart代表Flutter侧开始订阅了,这时候才挂系统监听;onStreamEnd代表Flutter侧取消订阅,这时候卸载监听。这样做的好处是避免App没在前台时系统监听还一直挂着,白白耗电。
// OpenHarmony侧 this.eventChannel = new EventChannel('sim_manager/event', context); this.eventChannel.setStreamResult({ onStreamStart: () => { if (this.simStateListener) return; this.simStateListener = () => { this.pushSimState(); }; sim.on('simStateChange', this.simStateListener); }, onStreamEnd: () => { if (!this.simStateListener) return; sim.off('simStateChange', this.simStateListener); this.simStateListener = undefined; }, onStreamError: (error) => { // 事件流出错时的清理逻辑 } }); private pushSimState(): void { const slots = [0, 1]; const payloads = slots.map((slotId) => this.handleGetSimAccountInfo({ argument: () => ({ slotId }) } as any)); Promise.allSettled(payloads).then((results) => { const infos = results.map((r) => r.status === 'fulfilled' ? r.value : {}); this.eventChannel?.send(infos); }).catch(() => {}); }这个pushSimState方法里有个设计取舍:系统回调只告诉我“SIM状态变了”,但没告诉我变了哪张卡、变成什么状态。所以每次回调我都把两个卡槽的状态全部重新查一遍,打包成一个数组推给Flutter。虽然理论上查两张卡有一点耗时,但SIM卡状态变化不是高频事件,插拔一次顶多触发一两次回调,这个开销可以忽略。换来的是Flutter侧逻辑极其简单:收到数组直接替换UI数据源,不需要自己做差值比较。
Flutter侧的订阅代码也一并列出来。我在init()里先拉一次全量快照,然后注册EventChannel的监听。这里要强调一个次序:先查后订阅,不能反。如果先订阅再查询,会出现一个极小的窗口期——原生侧推了事件,但Flutter侧还没有初始数据,UI可能短暂闪烁。
Future<void> init() async { await _refreshSnapshot(); const EventChannel eventChannel = EventChannel('sim_manager/event'); _eventSubscription = eventChannel .receiveBroadcastStream() .map((event) => parseSimInfoList(event)) .listen((infos) { _simInfos = infos; notifyListeners(); }); }有人可能会问:为什么不用receiveBroadcastStream().listen直接拿原生推的数据,还要先_refreshSnapshot()?因为EventChannel开流之后,只有当原生侧send数据时Flutter才能收到,而App刚启动那一刻原生侧可能还没准备好事件源。先拉快照能保证订阅之前UI就有数据可渲染,订阅只是做一个增量刷新。
4.4 业务页面联动:卡片状态、下拉刷新、异常提醒
数据通了之后,UI层就比较好写了。我在首页放了两张SIM卡卡片,分别对应slotId 0和slotId 1。每张卡片显示运营商名称、状态标签、IMSI/ICCID(按权限是否放开决定显隐),同时在卡片右上角标注“默认数据卡”标识。这套UI用Flutter的Card加ListTile就能实现,没有特殊组件,重点在数据驱动的交互上。
下拉刷新用的是RefreshIndicator,触发时调用simCardRepository.refresh(),也就是强制重新走一遍查询链路。这个动作在业务里很有用:用户换了一张卡后,系统事件监听大概率会触发,但偶尔会有事件丢失的情况(下文会讲),下拉刷新是兜底手段。
异常提醒我在Flutter侧做了一层WatchDog逻辑:每次拿到SIM卡数据后,如果某张卡的状态不是ready,且持续时间超过30秒,就推送一条本地提醒。这里有几个边界情况要考虑:双卡设备只有一张卡是正常的,另外一张卡可能本来就是空的(not_present),不能算异常;设备开飞行模式时两张卡都不是ready,也不能算异常。所以我的判断条件收紧到“上一帧数据状态是ready,这一帧变成locked或deactivated”,只有这种状态跳变才触发提醒。
5. 实战问题与排查记录
5.1 权限收不到或被拒的几类原因
权限问题是我在这台设备上折腾最久的一环。排查经验可以按现象拆:
第一种现象是调用getSIMAccountInfo直接抛异常,错误码是201或202。201代表权限验证失败,202代表没有权限。出现这两个错误码,九成是module.json5里的声明没配全,或者签名文件里的ACL没同步。
第二种现象是接口不报错,但返回的imsi、iccid是空字符串。这就是我前面讲的受限字段问题。权限白名单没通过,OpenHarmony不会给你报错,只是默默把敏感字段清空。要排查这个问题,最有效的办法是在原生侧打一条日志,把info.getFieldValue的结果直接打印出来,如果日志里是空串,就走ACL申请流程;如果日志里有值但Flutter侧是空,那就是序列化环节的问题,跟权限无关。
第三种现象比较隐蔽:App在DevEco Studio调试模式能拿到数据,但打包上真机之后突然拿不到了。这类问题基本是调试证书和发布证书的权限范围不一致导致的。开发环境签名证书一般会有临时的ACL授权,正式签名不一定含这个权限。
我在项目里加了一个诊断模式,在App设置页里打开“开发者选项”后,用一个Debug页面直接列出全部权限的申请状态、签名级别、受限字段实际返回结果。这个页面只会在Debug构建里出现,Release构建完全移除,既方便自己排查,又不会给用户造成困惑。
5.2 EventChannel在后台收不到事件
EventChannel在App切后台后收不到状态变化,这个问题我一开始也以为是框架bug,后来发现是Flutter引擎在后台被挂起导致的。OpenHarmony上Flutter引擎默认在页面进入后台后会暂停Dart侧的微任务和UI帧刷新,原生侧虽然还在推送事件,但Dart侧根本没有机会处理。
解决思路分两层。第一层是我在原生侧做了一个事件缓冲:如果检测到Flutter侧当前不可用(通过插件生命周期标志判断),就把最新状态缓存到内存里,等App回前台后Flutter侧主动拉取一次快照,把缓存和最新数据合并。第二层是如果监管场景必须要求后台也能及时提醒,那就不能完全依赖Flutter引擎,得在OpenHarmony侧用系统级提醒能力兜底——也就是原生侧在检测到SIM卡状态跳变时,直接发一条系统通知,不经过Flutter侧。这样即使Flutter引擎没在跑,用户也能看到提醒。
这个设计需要权衡:如果监管App的核心价值是“可靠提醒”,建议从一开始就把提醒链路放在原生侧,Flutter侧只负责配置和展示。否则就会遇到App被系统杀掉后一切功能失效的尴尬。
5.3 双卡slotId错乱
双卡设备的槽位索引是从0开始的,slotId 0和slotId 1分别对应物理卡槽一和卡槽二。听起来简单,但实际翻车点在于:很多OpenHarmony设备的API实现里,没插卡的槽位也会返回一个对象,state是NOT_PRESENT,但其他字段全为空。如果原生侧代码没有判断isPresent,直接把这些空字段包装进JSON发给Flutter,UI层就会把一张不存在的卡渲染出来,还带着空运营商、空IMSI,界面看起来就像出了bug。
我踩过的另一个坑是:默认数据卡切换后,旧的事件流里可能还带着上一次的状态。比如用户原来用卡0上网,后来切换成卡1,系统触发simStateChange时,原生侧如果只推了卡1的信息,Flutter侧没有同时更新卡0的isDefaultDataSlot字段,就会出现“两张卡都显示自己是默认数据卡”的错乱。所以我在pushSimState里强制同时查询两个槽位,这样每次事件推给Flutter的都是一张完整的两卡快照,彻底规避了部分字段过期的问题。
5.4 序列化与线程注意事项
MethodChannel回调在OpenHarmony侧返回的Map,如果value里夹杂了undefined或者null,Dart侧的JSON解析会直接抛异常。我的经验是原生侧在组JSON时,所有字段都显式设置默认值,能强转的强转,不能强转的给空字符串,保证发送出去的序列化对象是“干净的”。
另一个细节是线程。OpenHarmony的Telephony回调默认跑在系统通信线程上,如果我不做处理直接在这个线程里调用EventChannel的send方法,有时会因为线程归属问题导致Flutter侧收不到事件(表现是偶发丢失)。保险做法是把事件推送切到插件自己的异步任务线程,或者保证所有推送操作都在同一个固定的线程中执行。我在插件里加了一个单线程调度器,所有send走这个调度器,事件丢失率直接清零。
最后说一个测试技巧:我把两张卡里的一张设置成“仅限2G/3G”,另一张正常4G/5G,然后持续切换默认数据卡,每切换一次检查Flutter侧两卡数据是否正确刷新。这个操作能同时覆盖slotId识别、isDefaultDataSlot更新、事件流推送三个风险点,实测下来比单纯插拔卡有效得多。
6. 监管场景下的合规边界与体验细节
6.1 告知授权不能省
移动数据使用监管App天然涉及用户敏感信息,IMSI、ICCID、运营商、号码归属地这些数据,放在任何应用市场上都是隐私合规审查的重点。我在这类项目上的底线是:所有SIM卡信息相关的权限,在App首次启动时集中弹窗说明一次,并且必须有明确的文案告诉用户为什么需要这些权限,数据用在哪里,能实现什么功能。
具体做法是,在权限申请之前先展示一页“功能说明+隐私摘要”,用户点击同意后才触发OpenHarmony的系统权限弹窗。这里不要偷懒两端一次性弹完所有权限,用户面对连续五六个弹窗会产生很强的排斥感。我的经验是分两个阶段:核心权限(GET_TELEPHONY_STATE)在用户进入SIM卡管理页面前询问,次要权限在相关功能首次使用时再询问。
另外还要注意:监管App的定位决定它会长期在后台运行,OpenHarmony对后台权限的审核越来越严格。如果App没有明确的锁屏保活需求,不要轻易申请后台保活权限,这既影响审查通过率,也容易在用户侧留下“流氓App”的印象。
6.2 低打扰式提醒设计
SIM卡状态变化的提醒很容易写得惹人烦。拔卡提醒、锁卡提醒、切卡提醒,每一条都推通知,用户手机在一天内可能收到十几条“异常警告”。我最后收敛成的方案是分级提醒:
- 信息提示级(无通知,仅App内列表记录):默认数据卡切换、漫游状态变化。
- 关注级(通知栏提醒):SIM卡状态从ready跳变到locked或deactivated。
- 严重级(通知栏提醒+震动+可选铃声):SIM卡拔出且持续超过N分钟未恢复,此时监管方大概率需要介入。
这个分级策略的核心是减少误报。我在真机测试中发现,设备重启过程中SIM卡状态会经历一轮UNKNOWN和NOT_PRESENT的波动,如果把这些波动全部当“拔卡异常”上报,用户会被吓到。所以我的WatchDog里其实多了一层“冷却时间”——同一个槽位同一个状态变化,5分钟内不重复上报。这个设计实测下来非常有效,用户的吐槽率直线下降。
6.3 数据链路联动思路
SIM卡管理不是孤立的,它要为数据流量监管提供底座。这里给一个扩展思路:拿到默认数据卡信息后,可以把流量的“归属”映射到SIM卡的账户信息上。比如通过telephony.getNetworkState拿到的网络状态,结合当前默认数据卡和运营商信息,就能在App里画出一条“当前数据链路”的可视化路径:手机 → 默认数据卡 → 运营商网络 → 互联网服务。
我在这套方案上跑了一个版本,流量统计页面做得比较简单:每个卡槽对应一个流量总数,默认数据卡的历史流量按分钟级拆分,监管方可以查看“某段时间内哪张卡在消耗流量”。这个逻辑不需要在原生侧做额外工作,因为SIM卡模块已经把数据通道的归属字段全部提供了。
如果后续要接更细粒度的流量统计,可以考虑对接OpenHarmony的数据流量管理服务,按应用维度拆分每个App消耗的流量。但这一步要评估设备厂商的实现差异,不是所有OpenHarmony设备都支持应用级流量统计。所以我在方案设计上把SIM卡管理当作“一定可靠”的地基,应用级统计当作“尽力而为”的增强项,核心功能不会因为增强项不可用就失效。
7. 最后的实操体会
整个SIM卡管理模块做完,我最深的体会是:这类系统级能力模块的难点从来不在API本身,而在于把“跨端通信、权限边界、状态语义”三件事对齐。Flutter侧代码写得再漂亮,原生侧只要把权限字段悄悄清空,或者推送一个类型不符合预期的Map,UI层就得花半天时间排查。
我自己现在做这类插件有个固定习惯:先定数据模型,再写通道代码,最后才接UI。数据模型是两端沟通的契约,通道是实现,UI只是消费者。顺序一旦反了,后面每接一个业务点都要回头改通道,成本翻倍。
最后分享一个小技巧:开发期间给MethodChannel和EventChannel各加一个统一的日志开关,在Debug模式下把所有方法名、参数、返回结果、事件流内容全部打印出来。SIM卡管理这类问题,九成的定位时间都花在“确认对方到底发/收了什么”上,有了这个日志,基本上看一眼就能判断问题在哪一端。这个日志开关在Release构建里记得关掉,不要把自己的数据格式暴露给第三方抓包分析。