幕思城 > 电商行情 > 多多开店 > 多多运营 > Flutter 长截屏适配 Miui 系统,一点都不难

    Flutter 长截屏适配 Miui 系统,一点都不难

    2022-11-30 | 13:21 | 发布在分类 / 多多运营 | 阅读:106

    现有 App 大部分业务场景都是以长列表呈现,为更好满足用户内容分享的诉求,Android 各大厂商都在系统层面提供十分便捷的长截屏能力。然而我们发现 Flutter 长列表页面在部分 Android 手机上无法截长屏,Flutter 官方和社区也没有提供框架层面的长截屏能力。

    闲鱼作为 Flutter 在国内业务落地的代表作,大部分页面都以 Flutter 承接。为了闲鱼用户也能享受厂商系统的长截屏能力,更好的满足商品、社区内容分享的诉求,闲鱼技术团队主动做了分析和适配。

    针对线上舆情做了统计分析,发现小米用户舆情反馈量占比最多,其次少量是华为用户。为此我们针对 Miui 长截屏功能做了适配。


    这里华为、OPPO、VIVO 基于无障碍服务实现,长截屏功能已经适配 Flutter 页面。这里少量用户反馈,是因为截屏反馈小把手 PopupWindow 有可能出现遮挡,导致系统无法驱动长列表滚动。通过重写 isImportantForAccessibility 便能解决。
    小米长截屏解读

    操作和表现

    小米手机可通过音量键+电源键、或顶部下拉功能菜单“截屏”,触发截屏。经过简单尝试,可以发现,原生长列表页面支持截长屏,原生页面无长列表不支持,闲鱼 Flutter 长列表页面(如详情页、搜索结果页)不支持。


    点击“截长屏”后,能看到长列表页面会自动滚动,点击结束或者触底的时候,自动打开图片编辑页面,能看到生成的长截图。那小米系统是如何无侵入的实现以下关键点:
    1. 1. 当前页面是否支持滚动截屏(长截屏 按钮是否置灰)
    2. 2. 如何触发 App 长列表页面滚动
    3. 3. 如何判断是否已经滚动触底
    4. 4. 如何合成长截图

    系统源码获取


    小米厂商能判断前台 App 页面能否滚动,必然需要调用前台 App 视图的关键接口来获取信息。编写一个自定义 RecyclerView 列表页面,日志输出 RecycleView 方法调用:


    已知长截屏需要调用的方法,再查看堆栈,可以看到调用方是系统类:miui.util.LongScreenshotUtils&ContentPort

    使用低版本 miui(这里 miui8)手机,获取对应的代码:/system/framework/framework.jar 或 github 查找 miui 开放代码。

    实现原理介绍

    整体流程:查找滚动视图 → 驱动视图滚动 → 分段截图→截图内容合并

    查找滚动视图

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

    触发视图滚动

    1. 1. 每次滚动前,使用 canScrollVertically(1) 判断是否向下滚动
    2. 2. 触发滚动逻辑
    3. a. 特殊视图: dispatchFakeTouchEvent(2);privatebooleancheckNeedFakeTouchForScroll() {
    4. if((this.mMainScrollView instanceof AbsListView) ||
    5. (this.mMainScrollView instanceof ScrollView) ||
    6. isRecyclerView(this.mMainScrollView.getClass()) ||
    7. isNestedScrollView(this.mMainScrollView.getClass())) {
    8. returnfalse;
    9. }
    10. return!(this.mMainScrollView instanceof AbsoluteLayout) ||
    11. (Build.VERSION.SDK_INT >19&&
    12. !"com.ucmobile".equalsIgnoreCase(this.mMainScrollView.getContext().getPackageName()) &&
    13. !"com.eg.android.AlipayGphone".equalsIgnoreCase(this.mMainScrollView.getContext().getPackageName()));
    14. }
    15. b. AbsListView: scrollListBy(distance);
    16. 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
    publicfinalintgetScrollY() {
    return mScrollY;
    }

    系统事件代理


    转变思路,我们并不需要让 Flutter 容器被 Miui 系统识为可滚动视图,而是让 Flutter 接收到 Miui 系统指令。为此,我们构建一个不可见、不影响交互的滚动视图 ControlView 被 Miui 系统识别,并接收系统指令。ControlView 最后把指令传递给 Flutter,最终建立了 Miui 系统(ContentPort)和闲鱼 Flutter(可滚动 RenderObject)之间的通信。
    其中通信事件:
    1. 1. void scrollBy(View view, int x, int y)
    2. 2. boolean canScrollVertically(View view, int direction, boolean startScreenshot)
    3. 3. int getScrollY(View view)

    关键实现源码如下
    publicstatic FrameLayout setupLongScreenshotSupport(FrameLayout parent,
    View targetChild,
    IMiuiLongScreenshotViewDelegate delegate) {
    Contextcontext= targetChild.getContext();
    MiuiLongScreenshotViewscreenshotView=newMiuiLongScreenshotView(context);
    screenshotView.setDelegate(delegate);
    screenshotView.addView(targetChild, newFrameLayout.LayoutParams(
    ViewGroup.LayoutParams.MATCH_PARENT,
    ViewGroup.LayoutParams.MATCH_PARENT));
    MiuiLongScreenshotControlViewcontrolView=newMiuiLongScreenshotControlView(context);
    controlView.bindRealScrollView(screenshotView);
    if(parent == null) {
    parent = newFrameLayout(context);
    }
    parent.addView(screenshotView, newFrameLayout.LayoutParams(
    ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
    parent.addView(controlView);
    return parent;
    }publicclassMiuiLongScreenshotControlViewextendsScrollView
    implementsMiuiScreenshotBroadcast.IListener {
    private IMiuiLongScreenshotView mRealView;
    ...
    publicvoidbindRealScrollView(IMiuiLongScreenshotView v) {
    mRealView = v;
    removeAllViews();
    Contextcontext= getContext();
    LinearLayoutll=newLinearLayout(context);
    addView(ll);
    Viewbtn=newView(context);
    LinearLayout.LayoutParamslp=newLinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
    UIUtil.dp2px(context, 20000));
    ll.addView(btn, lp);
    resetScrollY(true);
    }
    publicvoidresetScrollY(boolean startScreenshot) {
    if(mRealView != null) {
    setScrollY(0);
    if(getWindowVisibility() == VISIBLE) {
    ThreadUtil.runOnUI(()
    ->mRealView.canScrollVertically(1, startScreenshot));
    }
    }
    }
    @Override
    publicvoidonReceiveScreenshot() {
    // 每次收到截屏广播,将 ControlView 滚动距离置 0
    // 提前查找滚动 RenderObject 并缓存
    // 提前计算 canScrollVertically
    resetScrollY(true);
    }
    @Override
    protectedvoidonAttachedToWindow() {
    super.onAttachedToWindow();
    mContext = getContext();
    // 截屏广播监听
    MiuiScreenshotBroadcast.register(mContext, this);
    }
    @Override
    protectedvoidonDetachedFromWindow() {
    super.onDetachedFromWindow();
    MiuiScreenshotBroadcast.unregister(mContext, this);
    }
    @Override
    publicbooleancanScrollVertically(int direction) {
    if(mRealView != null) {
    return mRealView.canScrollVertically(direction, false);
    }
    returnsuper.canScrollVertically(direction);
    }
    @Override
    publicvoidscrollBy(int x, int y) {
    super.scrollBy(x, y);
    if(mRealView != null) {
    mRealView.scrollBy(x, y);
    }
    }
    // 代理获取 DrawingCache
    @Override
    publicvoidsetDrawingCacheEnabled(boolean enabled) {
    super.setDrawingCacheEnabled(enabled);
    if(mRealView != null) {
    mRealView.setDrawingCacheEnabled(enabled);
    }
    }
    @Override
    publicbooleanisDrawingCacheEnabled() {
    if(mRealView != null) {
    return mRealView.isDrawingCacheEnabled();
    }
    returnsuper.isDrawingCacheEnabled();
    }
    @Override
    public Bitmap getDrawingCache(boolean autoScale) {
    Bitmapresult=(mRealView != null)
    ? mRealView.getDrawingCache(autoScale)
    : super.getDrawingCache(autoScale);
    return result;
    }
    @Override
    publicvoiddestroyDrawingCache() {
    super.destroyDrawingCache();
    if(mRealView != null) {
    mRealView.destroyDrawingCache();
    }
    }
    @Override
    publicvoidbuildDrawingCache(boolean autoScale) {
    super.buildDrawingCache(autoScale);
    if(mRealView != null) {
    mRealView.buildDrawingCache(autoScale);
    }
    }
    // 不消费屏幕操作事件
    @Override
    publicbooleanonInterceptTouchEvent(MotionEvent ev) {
    returnfalse;
    }
    @Override
    publicbooleanonTouchEvent(MotionEvent ev) {
    returnfalse;
    }
    }

    无侵入识别滚动区域

    获取 RenderObject 根节点


    使用 mixin 扩展 WidgetsFlutterBinding,进而获取 RenderView
    关键实现源码如下:
    mixin NativeLongScreenshotFlutterBinding on WidgetsFlutterBinding {
    @override
    void initInstances() {
    super.initInstances();
    // 初始化
    FlutterMiuiLongScreenshotPlugin.inst;
    }
    @override
    void handleDrawFrame() {
    super.handleDrawFrame();
    try{
    NativeLongScreenshot.singleInstance._renderView = renderView;
    } catch(error, stack) {
    }
    }
    }

    计算前台滚动 RenderObject


    其中第 2 步条件检查:
    1. 1. width >= RenderView.width/2
    2. 2. height >= RenderView.height/2
    3. 3. 类型是 RenderViewportBase
    4. 4. axis == Axis.vertical
    实现源码如下:
    RenderViewportBase? findTopVerticalScrollRenderObject(RenderView? root) {
    SizerootSize= size(root, Size.zero);
    // if (root != null) {
    // _debugGetRenderTree(root, 0);
    // }
    RenderViewportBase? result = _recursionFindTopVerticalScrollRenderObject(root, rootSize);
    if(_hitTest(root, result)) {
    return result;
    }
    returnnull;
    }RenderViewportBase? _recursionFindTopVerticalScrollRenderObject(
    RenderObject? renderObject, Size rootSize) {
    if(renderObject == null) {
    returnnull;
    }
    ///get RenderObject Size
    if(_tooSmall(rootSize, size(renderObject, rootSize))) {
    returnnull;
    }
    if(renderObject is RenderViewportBase) {
    if(renderObject.axis == Axis.vertical) {
    return renderObject;
    }
    }
    final ListQueuechildren = ListQueue();
    if(renderObject.runtimeType.toString() == '_RenderTheatre') {
    renderObject.visitChildrenForSemantics((RenderObject? child) {
    if(child != null) {
    children.addLast(child);
    }
    });
    } else{
    renderObject.visitChildren((RenderObject? child) {
    if(child != null) {
    children.addLast(child);
    }
    });
    }
    for(var child in children) {
    RenderViewportBase? viewport =
    _recursionFindTopVerticalScrollRenderObject(child, rootSize);
    if(viewport != null) {
    return viewport;
    }
    }
    returnnull;
    }
    找到首个满足条件的 RenderViewportBase 并不一定是我们需要的对象,如下图所示:闲鱼详情页通过上述方法能找到红色框的 RenderViewportBase,在左图情况下,能满足滚动截图要求;但在右图情况下,留言面板遮挡了长列表,此时红色框 RenderObject 并不是我们想要的。


    此刻我们需要检测 Widget 可见性/可交互检测能力。查看 Flutter 官方 visibility_detector组件并不满足我们的要求,其通过在子 Widget 上放置一个 Layer 来间接检测可见状态,但因为通过在屏幕内的宽高判断,无法检测 Widget 被遮挡的情况。
    左图长列表没有被遮挡,可以被操作;右图被留言面板遮挡,事件无法传递到长列表,无法被操作;为此,我们模拟用户的点击能否被触达来检测 RenderViewportBase 是否被遮挡,能否用来做长截屏滚动。
    特别注意的是,当 Widget 被 Listener 包装,事件消费会被 RenderPointerListener 拦截,如下图所示。

    查看 Flutter Framework 源码,Scrollable Widget 包装了 Listener,Semantics,IgnorePointer;闲鱼 PowerScrollView 使用了 ShrinkWrappingViewPort。为此,递归找到的 RenderSliverList 和点击测试找到的 RenderPointerListener 的距离为 5,如上图所示。

    点击测试校验代码如下
    bool_hitTest(RenderView? root, RenderViewportBase? result) {
    if(root == null|| result == null) {
    returnfalse;
    }
    Size rootSize = size(root, Size.zero);
    HitTestResult hitResult = HitTestResult();
    root.hitTest(hitResult, position: Offset(rootSize.width/2, rootSize.height/2));
    for(HitTestEntry entry in hitResult.path) {
    if(entry.target == result) {
    returntrue;
    }
    }
    /**
    * 处理如下 case
    * RenderPointerListener 2749d135
    RenderSemanticsAnnotations 1cd639bf
    RenderIgnorePointer 7e33fff
    RenderShrinkWrappingViewport 1167ca33
    */
    RenderPointerListener? pointerListenerParent;
    AbstractNode? parent = result.parent;
    constint lookUpLimit = 5;
    int lookupCount = 0;
    while(parent != null&&
    lookupCount < lookUpLimit &&
    parent.runtimeType.toString() != '_RenderTheatre') {
    lookupCount ++;
    if(parent is RenderPointerListener) {
    pointerListenerParent = parent;
    }
    parent = parent.parent;
    }
    if(pointerListenerParent != null) {
    for(HitTestEntry entry in hitResult.path) {
    if(entry.target == pointerListenerParent) {
    returntrue;
    }
    }
    }
    returnfalse;
    }

    异步 Channel 通信方案


    Flutter channel 通信方案如上图所示,其中 EventChannel 和 MethodChannel 运行在 Java 主线程,同 Dart Platform Isolate,而 Dart 层事件处理逻辑在 UI Isolate,为此并不在同一线程。可以发现,Java → Dart → Java 发生了 2 次线程切换。
    使用小米 K50 测试性能,从 EventChannel 发送事件 到 MethodChannel 接收返回值,记录耗时。可见,首次 canScrollVertically (由截屏广播触发)需要递归查找滚动组件,耗时为 10-30ms,之后耗时均在 5ms 以内。
    08-0816:15:56.0601107911079 E longscreenshot: canScrollVertically use_time=25
    08-0816:15:56.2781107911079 E longscreenshot: canScrollVertically use_time=2
    08-0816:16:05.3421107911079 E longscreenshot: canScrollVertically use_time=10
    08-0816:16:05.5621107911079 E longscreenshot: canScrollVertically use_time=1

    为保证在异步调用的情况下,MIUI ContentPort 下发命令均能获取到最新值,这里做以下特殊处理
    1. 1. 截屏广播提前计算 canScrollVerticallly 并缓存结果
    2. 2. MIUI ContentPort 调用 canScrollVerticallly 直接返回最新缓存值,异步触发计算
    3. 3. MIUI ContentPort 调用 scrollBy 后,及时更新 canScrollVerticallly 和 getScrollY 缓存值

    同步 FFI 通信方案

    异步调用方案,在高端机且 App 任务队列无阻塞情况下,能正确且准确运行,但在低端机和 App 任务较重时,可能存在返回 ContentPort 数据非最新的情况,为此我们考虑使用 FFI 同步通信的方案。


    以上同步方案,一次同步调用性能分析,基本在 5ms 以内:

    关键实现代码如下:
    @Keep
    publicclassNativeLongScreenshotJniimplementsSerializable{
    static{
    System.loadLibrary("flutter_longscreenshot");
    }
    publicstaticnativevoidnativeCanScrollVertically(int direction,
    boolean startScreenshot,
    int callbackId);
    publicstaticnativevoidnativeGetScrollY(int screenWidth, int callbackId);
    publicstaticnativevoidnativeScrollBy(int screenWidth, int x, int y);
    publicstaticbooleancanScrollVertically(finalint direction,
    finalboolean startScreenshot) {
    FlutterLongScreenshotCallbacks.AwaitCallbackcallback=
    FlutterLongScreenshotCallbacks.newCallback();
    nativeCanScrollVertically(direction, startScreenshot, callback.id());
    intresult= callback.waitCallback().getResult();
    returnresult== 1;
    }
    publicstaticintgetScrollY(finalint screenWidth) {
    FlutterLongScreenshotCallbacks.AwaitCallbackcallback=
    FlutterLongScreenshotCallbacks.newCallback();
    nativeGetScrollY(screenWidth, callback.id());
    // waitCallback 同步等待 C++ 调用 FlutterLongScreenshotCallbacks.handleDartCall
    intresult= callback.waitCallback().getResult();
    return result;
    }
    publicstaticvoidscrollBy(int screenWidth, int x, int y) {
    nativeScrollBy(screenWidth, x, y);
    }
    }
    @Keep
    publicclassFlutterLongScreenshotCallbacksimplementsSerializable{
    publicstatic AwaitCallback newCallback() {
    AwaitCallbackcallback=newAwaitCallback();
    CALLBACKS.put(callback.id(), callback);
    return callback;
    }
    // C++ DART_EXPORT void resultCallback(int callbackId, int result) 反射调用
    publicstaticvoidhandleDartCall(int id, int result) {
    AwaitCallbackcallback= CALLBACKS.get(id);
    if(callback != null) {
    CALLBACKS.remove(id);
    callback.release(result);
    }
    }
    privatestaticfinal SparseArrayCALLBACKS = newSparseArray<>();
    @Keep
    publicstaticclassAwaitCallback{
    publicstaticfinalintRESULT_ERR=-1;
    privatefinalCountDownLatchmLatch=newCountDownLatch(1);
    privateintmResult= RESULT_ERR;
    publicintid() {
    return hashCode();
    }
    public AwaitCallback waitCallback() {
    try{
    mLatch.await(100, TimeUnit.MILLISECONDS);
    } catch(Throwable e) {
    e.printStackTrace();
    }
    returnthis;
    }
    publicvoidrelease(int result) {
    mResult = result;
    mLatch.countDown();
    }
    publicintgetResult() {
    return mResult;
    }
    }
    }
    void setDartInt(Dart_CObject& dartObj, int value) {
    dartObj.type = Dart_CObject_kInt32;
    dartObj.value.as_int32 = value;
    }
    JNIEXPORT void JNICALL
    nativeCanScrollVertically(
    JNIEnv *env, jclass cls,
    jint direction, jboolean startScreenshot, jint callbackId) {
    Dart_CObject* dart_args[4];
    Dart_CObject dart_arg0;
    Dart_CObject dart_arg1;
    Dart_CObject dart_arg2;
    Dart_CObject dart_arg3;
    setDartString(dart_arg0, strdup("canScrollVertically"));
    setDartInt(dart_arg1, direction);
    setDartBool(dart_arg2, startScreenshot);
    setDartLong(dart_arg3, callbackId);
    dart_args[0] = &dart_arg0;
    dart_args[1] = &dart_arg1;
    dart_args[2] = &dart_arg2;
    dart_args[3] = &dart_arg3;
    Dart_CObject dart_object;
    dart_object.type = Dart_CObject_kArray;
    dart_object.value.as_array.length = 4;
    dart_object.value.as_array.values = dart_args;
    Dart_PostCObject_DL(send_port_, &dart_object);
    }
    // getScrollY 和 scrollBy 实现类似DART_EXPORT void resultCallback(int callbackId, int result) {
    JNIEnv *env = _getEnv();
    if(env != nullptr) {
    auto cls = _findClass(env, jCallbackClassName);
    jmethodID handleDartCallMethod = nullptr;
    if(cls != nullptr) {
    // 调用 java 代码 FlutterLongScreenshotCallbacks.handleDartCall(int id, int result)
    handleDartCallMethod = env->GetStaticMethodID(cls,
    "handleDartCall", "(II)V");
    }
    if(cls != nullptr && handleDartCallMethod != nullptr) {
    env->CallStaticVoidMethod(cls, handleDartCallMethod,
    callbackId, result);
    } else{
    print("resultCallback. find method handleDartCall is nullptr");
    }
    }
    }classNativeLongScreenshotextendsObject{
    ...
    late final NativeLongScreenshotLibrary _nativeLibrary;
    late final ReceivePort _receivePort;
    late final StreamSubscription _subscription;
    NativeLongScreenshot() {
    ...
    _nativeLibrary = initLibrary();
    _receivePort = ReceivePort();
    varnativeInited=_nativeLibrary.initializeApi(
    ffi.NativeApi.initializeApiDLData
    );
    assert(nativeInited == 0, 'DART_API_DL_MAJOR_VERSION != 2');
    _subscription = _receivePort.listen(_handleNativeMessage);
    _nativeLibrary.registerSendPort(_receivePort.sendPort.nativePort);
    }
    void_handleNativeMessage(dynamic inArgs) {
    Listargs = inArgs;
    Stringmethod= args[0];
    switch(method) {
    case'canScrollVertically': {
    intdirection= args[1];
    boolstartScreenshot= args[2];
    intcallbackId= args[3];
    finalboolcanScroll= canScrollVertically(direction, startScreenshot);
    intresult= canScroll ? 1: 0;
    _nativeLibrary.resultCallback(callbackId, result);
    } break;
    case'getScrollY': {
    intnativeScreenWidth= args[1];
    intcallbackId= args[2];
    intresult= getScrollY(nativeScreenWidth);
    _nativeLibrary.resultCallback(callbackId, result);
    } break;
    case'scrollBy': {
    intnativeScreenWidth= args[1];
    intnativeX= args[2];
    intnativeY= args[3];
    scrollBy(nativeY, nativeScreenWidth);
    } break;
    }
    }
    }总结
    完成国内主要机型适配,现在线上几乎不再有用户反馈 Flutter 页面不支持长截屏。闲鱼 Android 用户已经能用系统长截屏能力,分享自己喜欢的商品、圈子内容,卖家能使用一张图片推广自己的全部商品,买家能帮助家里不会用 App 的老人找商品。
    面对系统功能适配,业务 App 侧也并不是完全束手无策。通过以下过程便有可能找到解决之道:
    • • 合理猜想(系统模块会调用业务视图接口)
    • • 工具辅助分析和验证(ASM 代码 hook,日志输出)
    • • 源码查找和截图(代码查找和反编译)
    • • 发散思考(ControlView 顶替 Flutter 容器,瞒天过海)
    • • 方案实现(业务无侵入,一次实现全部业务页面适配)

    这个问题还有疑问的话,可以加幕.思.城火星老师免费咨询,微.信号是为: msc496。

    难题没解决?加我微信给你讲!【仅限淘宝卖家交流运营知识,非卖家不要加我哈】
    >

    推荐阅读:

    速卖通用什么收款?如何设置收款方式?

    淘宝刷访客平台有哪些?怎么刷比较安全?

    快手极速回款有什么优势?怎么使用

    更多资讯请关注幕 思 城。

    发表评论

    别默默看了 登录 \ 注册 一起参与讨论!

      微信扫码回复「666