0%

一篇关于Flutter的性能优化与安卓原生进行混合开发的踩坑记录

前言

这个项目的背景是一个给用户提供一个利用 RFID 来寻找特定标签绑定的物体的功能开发,其中遇到了 Flutter 端的性能瓶颈、Android 端的性能瓶颈


性能瓶颈

Flutter

Flutter 端的性能瓶颈很多,其中 UI 的渲染瓶颈是我们在开发中最常见的问题。
当时我遇到的问题是:当我不断的让设备扫描 RFID 时,持续了半分钟后,软件就会直接卡住,虽说还是可以进行一定的按钮交互与列表拖动,但是他们几乎不会有任何的响应,只有我先前写好的展示切换过程的中间态提示。且获取 RFID 的信号强度很不稳定,因为用于提示用户的提示音效是根据 RFID 的 RSSI 信号强度来判定的,所以音效非常不稳定。

Android

Android 端的性能瓶颈来源也很多,但 Android 性能瓶颈的场景和 Flutter 端是同时出现的。
表现为:当我不断的让设备扫描 RFID 时,持续了半分钟后,软件就会直接卡住,RFID 的扫描速度大大降低。

整体

这个项目中的功能其实主要有两个,一个是利用扫描头的能力扫描 RFID 标签,一个是利用摄像头的能力扫描条形码。启动摄像头扫描条形码的能力很快,处理速度也很快。
但问题就出现在扫描 RFID 的扫描头上,为了省电,我们需要给扫描头进行 上电开启扫描 的两次广播,才能开始我们的扫描工作。在实际使用中,在这两个功能之间进行高频切换其实是有较高需求的,但是目前的切换速度没法满足实际的需求。


问题分析

Flutter

首先,我们从 Flutter 的底层入手,首先它是一个单线程的框架,但可以自己手动开启后台线程来达到性能优化的目的。
而此时我的整个应用的 Flutter 端的 UI 、处理 Android 端发送来的数据,全都是塞在了同一个主线程中,这种每获取一次数据就进行整个 UI 页面的刷新,导致了该瓶颈的出现。
问题分析出来了,为以下几点:

  1. 将我们的 UI 渲染线程与处理数据的线程放在了同一个线程中,而 Flutter 框架被处理数据、渲染 UI 这两件事情大量的、重复的进行事件处理,导致的进程近乎阻塞了
  2. 该场景下 RFID 在现实中其实会更加堆叠的、重复的出现,我们并没有做屏蔽其他其实并不被需要更新的 RFID 标签的处理。
  3. 我们用了列表组件来渲染出我们所能够扫描到的所有 RFID 标签,但这个列表组件它并没有被进行更细致的拆分里面具体的每一个小组件
  4. 数据过于的原始了,并没有做任何的处理

Android

在 Android 端,我们的任务只有:调用别人开发好的提供调用扫描头的能力的 jar 包,并将这些数据都通过 Flutter 的 MethodChannel 通道注入,将方法传回给 Flutter 进行调用。
并且在开发文档中,有写到 能够让扫描头只扫描特定的 RFID 标签可调整扫描的功率
而在我当时的代码是这样的:

  1. 全量扫描能够扫描到的 RFID 标签
  2. 扫描 RFID 标签的方法与回传数据的方法全部也都是放在同一个进程内

整体

进入这个小项目时是作为另一个项目中的子功能的其中一个页面进入的。因此如何设计一个比较无感的启动环境、保持 UI 主进程流畅同时保证功能能够正常的运行,就是我们需要进行的目标。
而此时我们的各个事件实则为按需加载,没点到时就完全不调用,但我们其实可以利用一个更取巧的办法来“欺骗”用户这个启动速度特别快。


解决方法

Flutter

针对问题一,我们可以这样:
在 Flutter 端给接收从 Android 端传来的数据单独开启一个后台线程进行处理,将 UI 渲染的主线程与处理数据的后台线程进行隔离。以达到 UI 线程始终保持顺畅的能力,把所有的重活都放在用户无法感知的后台线程中。

  • 好处:简单的解决了这一个 UI 性能上的问题
  • 坏处:实际上性能的问题并没有真正的被解决,只是被隐藏或推迟了

针对问题二,我们可以这样:
在列表组件中,本身需要展示的、需要的其实目标其实只有目标 RFID 标签一个。因此我们可以直接过滤掉不需要的列表部分
好处:直接把 UI 压力从原本的 O(n) 降低到了 O(1)
坏处:这个行为是一个保留目标 RFID 的操作,当用户退出寻找特定 RFID 的时候,列表中只剩下这一个,如果用户想要找另外的,就需要进行重新扫描一遍 RFID 标签 但其实在功能的设计上,这个没有问题,因为到时候的数据源是从云端传到这个页面中的,而不是用户自己扫描再指定

针对问题三,我们可以这样做:
再列表组件中,细化到每一个列表组件,其实我们需要更新的内容并不多,只有一个 RSSI 信号强度与一个类似于 WIFI 信号强度的图标,其他的 RFID 的 EPC 值和 TID 值其实是不需要更新的。那我们就再把需要更新的组件拆出来,让他们单独进行 UI 的局部更新,减少更多的 UI 重绘事件。
好处:极大的降低 FLutter 引擎的渲染压力,同样的把一个 O(n) 的压力降低为了 O(1) 的压力
坏处:貌似没有,最多也就是代码变得更加复杂了一点

针对问题四,我们可以这样做:
在我查阅了一下资料后,得知了一个叫做 指数加权算法,对 Android 端传来的 RSSI 信号数据做预处理,因为用户本身在寻找某一个不确定的物体时,是缓慢的行动的,而不是发了疯一样的到处乱晃设备。因此这个场景下利用这个算法就能够很好的解决在现实世界中 RSSI 信号并非如距离与扫描头的发射功率不成正比的异常情况。
好处:信号更加平滑稳定了
坏处:其实并没有从硬件上根源性的解决问题,性能开销更大了

Android

针对问题一,我们可以这样做:
既然提供的 jar 包已经提供了能够让扫描头只扫描特定的 RFID 标签,那我们就调用它的这个能力就好了。
好处:更加省电、数据获取的压力更小了
坏处:在这个应用场景下貌似也没有坏处

针对问题二,我们可以这样做:
根据实际使用时,我们的用户在操作时获取数据和回传数据应该是同时进行的,那我们只要把这两个类型的事件进行隔离与加入异步的方式,来调整它的性能在同一个进行可能会导致的事件阻塞的性能问题,与开启异步的方式,让后台不会被一个事件执行而卡住。
好处:使用体验大大提升,能够处理更多复杂的任务
缺点:对于设备的本身性能要求变高了 虽然本身就是这样这么高,只是全部塞进同一个线程导致的他不会去过多的消耗其他的资源而已。但本身就是专门的在自己的专用设备上进行开发,所以开发环境就可以预测到生产环境中实际的性能表现,因此我可以随时调整该软件的性能需求

整体

这个其实才是困扰了我最久,思考了很久的东西。这里涉及到了状态机的设计,很有挑战性我也很喜欢。于是我设计了这么一个状态机:
整体下的状态机:

Off(第一次为初始化,第二次为真的关闭):后台悄悄启动 RFID 的扫描头,但都禁止扫描头与摄像头的实际调用;第二次进入时,关闭扫描头与摄像头的所有能力(包括断电)
Scanner:关闭 RFID 扫描能力,保留 RFID 在后台中的初始化状况。初始化并启动摄像头,等待用户按下实体按键激活摄像头进行扫描条形码
RFID:关闭 Scanner 扫描能力,保留 Scanner 在后台中的初始化状况。给扫描头上电并初始化,等待用户按下实体按键激活扫描头进行 RFID 标签的扫描。
以上的三种状态可以随意切换

进入到寻找某一个特定 RFID 标签时的寻物状态机:

NotFound:未找到目标 RFID 标签。播放 NotFound 的音效。扫描间隔时间为 700ms
Found:寻找到了目标 RFID 标签,播放 Found 的音效,并随着 RSSI 信号强度越大,但 RSSI 信号强度小于 95 ,音效的音量与播放频率会变得更高。扫描间隔时间为 50ms
Found_high:寻找到了目标且目标标签的 RSSI 信号强度大于等于 95,音效的音量与播放频率变得更高。扫描间隔时间为 50ms
以上的三种状态可以随意切换

后记

其实这里还有点小缺陷,提示音效的频率上限其实取决于我提供的音效文件的音频时长。
这个项目的两个小功能花了一个星期来实现,有很多东西都是我第一次遇到,很爽。真的有挑战性的工作,做完了之后的成就感就是不一样。
原本对于 Flutter 性能优化懵懵懂懂的概念,在这一个项目里面几乎全都给我遇上并解决了。

本项目的代码几乎全由 Codex 与 Claude 来完成,中间我负责主导了该项目的设计与优化的方案方向

Claude 是真的吃 token 啊,一天刷了我三个亿的 token
Codex 倒是干的活多,用的 token 还不多
↑ 相同的付费情况下