背景
现有 App 大部分业务场景都是以长列表呈现,为更好满足用户内容分享的诉求,Android 各大厂商都在系统层面提供十分便捷的长截屏能力。然而我们发现 Flutter 长列表页面在部分 Android 手机上无法截长屏,Flutter 官方和社区也没有提供框架层面的长截屏能力。
闲鱼作为 Flutter 在国内业务落地的代表作,大部分页面都以 Flutter 承接。为了闲鱼用户也能享受厂商系统的长截屏能力,更好的满足商品、社区内容分享的诉求,闲鱼技术团队主动做了分析和适配。
针对线上舆情做了统计分析,发现小米用户舆情反馈量占比最多,其次少量是华为用户。为此我们针对 Miui 长截屏功能做了适配。
这里华为、OPPO、VIVO 基于无障碍服务实现,长截屏功能已经适配 Flutter 页面。这里少量用户反馈,是因为截屏反馈小把手 PopupWindow 有可能出现遮挡,导致系统无法驱动长列表滚动。通过重写 isImportantForAccessibility 便能解决。
小米长截屏解读
操作和表现


1. 当前页面是否支持滚动截屏(长截屏 按钮是否置灰)
2. 如何触发 App 长列表页面滚动
3. 如何判断是否已经滚动触底
4. 如何合成长截图
系统源码获取
小米厂商能判断前台 App 页面能否滚动,必然需要调用前台 App 视图的关键接口来获取信息。编写一个自定义 RecyclerView 列表页面,日志输出 RecycleView 方法调用:已知长截屏需要调用的方法,再查看堆栈,可以看到调用方是系统类:
miui.util.LongScreenshotUtils&ContentPort

使用低版本 miui(这里 miui8)手机,获取对应的代码:/system/framework/framework.jar 或 github 查找 miui 开放代码。
实现原理介绍
整体流程:查找滚动视图 → 驱动视图滚动 → 分段截图→截图内容合并
查找滚动视图

其中检查条件:
1. View visibility == View.VISIBLE
2. canScrollVertically(1) == true
3. View 在屏幕内的宽度 > 屏幕宽度/3
4. View 在屏幕内的高度 > 屏幕高度/2
触发视图滚动

1. 每次滚动前,使用 canScrollVertically(1) 判断是否向下滚动
2. 触发滚动逻辑
a. 特殊视图: dispatchFakeTouchEvent(2);
private boolean checkNeedFakeTouchForScroll() {
if ((this.mMainScrollView instanceof AbsListView) ||
(this.mMainScrollView instanceof ScrollView) ||
isRecyclerView(this.mMainScrollView.getClass()) ||
isNestedScrollView(this.mMainScrollView.getClass())) {
return false;
}
return !(this.mMainScrollView instanceof AbsoluteLayout) ||
(Build.VERSION.SDK_INT > 19 &&
!"com.ucmobile".equalsIgnoreCase(this.mMainScrollView.getContext().getPackageName()) &&
!"com.eg.android.AlipayGphone".equalsIgnoreCase(this.mMainScrollView.getContext().getPackageName()));
}b. AbsListView: scrollListBy(distance);
c. 其他:view.scrollBy(0, distance);
3. 滚动结束,对比 scrollY 和 mPrevScrolledY 是否相同,相同则认为触底,停止滚动流程
生成长截图
每次滚动后广播,触发 mMainScrollView 局部截图,最后生成多个 Bitmap,最后合成 File 文件。在适配 Flutter 页面,这里并没有差异,所以这里就不做源码解读(不同 Miui 版本实现也有所不同)。
闲鱼适配方案
Flutter 长截屏不适配原因
通过分析源码可知,Flutter 容器(SurfaceView/TextureView) canScrollVertically 方法并未被重写,为此无法被找到作为 mMainScrollView。假如我们重写 Flutter 容器,我们需要真实实现 getScrollY 才能保证触发滚动后 scrollY 和 mPrevScrolledY 不相等。不幸的是,getScrollY 是 final 类型,无法被继承类重写,为此我们无法在 Flutter 容器上做处理。
@InspectableProperty
public final int getScrollY() {
return mScrollY;
}
系统事件代理
转变思路,我们并不需要让 Flutter 容器被 Miui 系统识为可滚动视图,而是让 Flutter 接收到 Miui 系统指令。为此,我们构建一个不可见、不影响交互的滚动视图 ControlView 被 Miui 系统识别,并接收系统指令。ControlView 最后把指令传递给 Flutter,最终建立了 Miui 系统(ContentPort)和闲鱼 Flutter(可滚动 RenderObject)之间的通信。
其中通信事件: