Flutter 长截屏适配 Miui 系统,一点都不难
背景
現有 App 大部分業務場景都是以長列表呈現,為更好滿足用戶內容分享的訴求,Android 各大廠商都在系統層面提供十分便捷的長截屏能力。然而我們發現 Flutter 長列表頁面在部分 Android 手機上無法截長屏,Flutter 官方和社區也沒有提供框架層面的長截屏能力。
閑魚作為 Flutter 在國內業務落地的代表作,大部分頁面都以 Flutter 承接。為了閑魚用戶也能享受廠商系統的長截屏能力,更好的滿足商品、社區內容分享的訴求,閑魚技術團隊主動做了分析和適配。
針對線上輿情做了統計分析,發現小米用戶輿情反饋量占比最多,其次少量是華為用戶。為此我們針對 Miui 長截屏功能做了適配。
這里華為、OPPO、VIVO 基于無障礙服務實現,長截屏功能已經適配 Flutter 頁面。這里少量用戶反饋,是因為截屏反饋小把手 PopupWindow 有可能出現遮擋,導致系統無法驅動長列表滾動。通過重寫 isImportantForAccessibility 便能解決。
小米長截屏解讀
操作和表現
小米手機可通過音量鍵+電源鍵、或頂部下拉功能菜單“截屏”,觸發截屏。經過簡單嘗試,可以發現,原生長列表頁面支持截長屏,原生頁面無長列表不支持,閑魚 Flutter 長列表頁面(如詳情頁、搜索結果頁)不支持。點擊“截長屏”后,能看到長列表頁面會自動滾動,點擊結束或者觸底的時候,自動打開圖片編輯頁面,能看到生成的長截圖。那小米系統是如何無侵入的實現以下關鍵點: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)之間的通信。
其中通信事件:
1.?void scrollBy(View view, int x, int y)
2.?boolean canScrollVertically(View view, int direction, boolean startScreenshot)
3.?int getScrollY(View view)
關鍵實現源碼如下
public?static?FrameLayout?setupLongScreenshotSupport(FrameLayout?parent,View?targetChild,IMiuiLongScreenshotViewDelegate?delegate)?{Context?context?=?targetChild.getContext();MiuiLongScreenshotView?screenshotView?=?new?MiuiLongScreenshotView(context);screenshotView.setDelegate(delegate);screenshotView.addView(targetChild,?new?FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT));MiuiLongScreenshotControlView?controlView?=?new?MiuiLongScreenshotControlView(context);controlView.bindRealScrollView(screenshotView);if?(parent?==?null)?{parent?=?new?FrameLayout(context);}parent.addView(screenshotView,?new?FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,?ViewGroup.LayoutParams.WRAP_CONTENT));parent.addView(controlView);return?parent; }public?class?MiuiLongScreenshotControlView?extends?ScrollViewimplements?MiuiScreenshotBroadcast.IListener?{private?IMiuiLongScreenshotView?mRealView;...public?void?bindRealScrollView(IMiuiLongScreenshotView?v)?{mRealView?=?v;removeAllViews();Context?context?=?getContext();LinearLayout?ll?=?new?LinearLayout(context);addView(ll);View?btn?=?new?View(context);LinearLayout.LayoutParams?lp?=?new?LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,UIUtil.dp2px(context,?20000));ll.addView(btn,?lp);resetScrollY(true);}public?void?resetScrollY(boolean?startScreenshot)?{if?(mRealView?!=?null)?{setScrollY(0);if?(getWindowVisibility()?==?VISIBLE)?{ThreadUtil.runOnUI(()?->?mRealView.canScrollVertically(1,?startScreenshot));}}}@Overridepublic?void?onReceiveScreenshot()?{//?每次收到截屏廣播,將?ControlView?滾動距離置?0//?提前查找滾動?RenderObject?并緩存//?提前計算?canScrollVerticallyresetScrollY(true);}@Overrideprotected?void?onAttachedToWindow()?{super.onAttachedToWindow();mContext?=?getContext();//?截屏廣播監聽MiuiScreenshotBroadcast.register(mContext,?this);}@Overrideprotected?void?onDetachedFromWindow()?{super.onDetachedFromWindow();MiuiScreenshotBroadcast.unregister(mContext,?this);}@Overridepublic?boolean?canScrollVertically(int?direction)?{if?(mRealView?!=?null)?{return?mRealView.canScrollVertically(direction,?false);}return?super.canScrollVertically(direction);}@Overridepublic?void?scrollBy(int?x,?int?y)?{super.scrollBy(x,?y);if?(mRealView?!=?null)?{mRealView.scrollBy(x,?y);}}//?代理獲取?DrawingCache@Overridepublic?void?setDrawingCacheEnabled(boolean?enabled)?{super.setDrawingCacheEnabled(enabled);if?(mRealView?!=?null)?{mRealView.setDrawingCacheEnabled(enabled);}}@Overridepublic?boolean?isDrawingCacheEnabled()?{if?(mRealView?!=?null)?{return?mRealView.isDrawingCacheEnabled();}return?super.isDrawingCacheEnabled();}@Overridepublic?Bitmap?getDrawingCache(boolean?autoScale)?{Bitmap?result?=?(mRealView?!=?null)??mRealView.getDrawingCache(autoScale):?super.getDrawingCache(autoScale);return?result;}@Overridepublic?void?destroyDrawingCache()?{super.destroyDrawingCache();if?(mRealView?!=?null)?{mRealView.destroyDrawingCache();}}@Overridepublic?void?buildDrawingCache(boolean?autoScale)?{super.buildDrawingCache(autoScale);if?(mRealView?!=?null)?{mRealView.buildDrawingCache(autoScale);}}//?不消費屏幕操作事件@Overridepublic?boolean?onInterceptTouchEvent(MotionEvent?ev)?{return?false;}@Overridepublic?boolean?onTouchEvent(MotionEvent?ev)?{return?false;} }無侵入識別滾動區域
獲取 RenderObject 根節點
使用 mixin 擴展 WidgetsFlutterBinding,進而獲取 RenderView
關鍵實現源碼如下:
mixin?NativeLongScreenshotFlutterBinding?on?WidgetsFlutterBinding?{@overridevoid?initInstances()?{super.initInstances();//?初始化FlutterMiuiLongScreenshotPlugin.inst;}@overridevoid?handleDrawFrame()?{super.handleDrawFrame();try?{NativeLongScreenshot.singleInstance._renderView?=?renderView;}?catch?(error,?stack)?{}} }計算前臺滾動 RenderObject
其中第 2 步條件檢查:
1.?width >= RenderView.width/2
2.?height >= RenderView.height/2
3.?類型是 RenderViewportBase
4.?axis == Axis.vertical
實現源碼如下:
RenderViewportBase??findTopVerticalScrollRenderObject(RenderView??root)?{Size?rootSize?=?size(root,?Size.zero);//?if?(root?!=?null)?{//?_debugGetRenderTree(root,?0);//?}RenderViewportBase??result?=?_recursionFindTopVerticalScrollRenderObject(root,?rootSize);if?(_hitTest(root,?result))?{return?result;}return?null; }RenderViewportBase??_recursionFindTopVerticalScrollRenderObject(RenderObject??renderObject,?Size?rootSize)?{if?(renderObject?==?null)?{return?null;}///get?RenderObject?Sizeif?(_tooSmall(rootSize,?size(renderObject,?rootSize)))?{return?null;}if?(renderObject?is?RenderViewportBase)?{if?(renderObject.axis?==?Axis.vertical)?{return?renderObject;}}final?ListQueue<RenderObject>?children?=?ListQueue<RenderObject>();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;}}return?null; }找到首個滿足條件的 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)?{return?false;}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)?{return?true;}}/***?處理如下?case*?RenderPointerListener?2749d135RenderSemanticsAnnotations?1cd639bfRenderIgnorePointer?7e33fffRenderShrinkWrappingViewport?1167ca33*/RenderPointerListener??pointerListenerParent;AbstractNode??parent?=?result.parent;const?int?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)?{return?true;}}}return?false; }異步 Channel 通信方案
Flutter channel 通信方案如上圖所示,其中 EventChannel 和 MethodChannel 運行在 Java 主線程,同 Dart Platform Isolate,而 Dart 層事件處理邏輯在 UI Isolate,為此并不在同一線程。可以發現,Java → Dart → Java 發生了 2 次線程切換。
使用小米 K50 測試性能,從 EventChannel 發送事件 到 MethodChannel 接收返回值,記錄耗時。可見,首次 canScrollVertically (由截屏廣播觸發)需要遞歸查找滾動組件,耗時為 10-30ms,之后耗時均在 5ms 以內。
為保證在異步調用的情況下,MIUI ContentPort 下發命令均能獲取到最新值,這里做以下特殊處理
1.?截屏廣播提前計算 canScrollVerticallly 并緩存結果
2.?MIUI ContentPort 調用 canScrollVerticallly 直接返回最新緩存值,異步觸發計算
3.?MIUI ContentPort 調用 scrollBy 后,及時更新 canScrollVerticallly 和 getScrollY 緩存值
同步 FFI 通信方案
異步調用方案,在高端機且 App 任務隊列無阻塞情況下,能正確且準確運行,但在低端機和 App 任務較重時,可能存在返回 ContentPort 數據非最新的情況,為此我們考慮使用 FFI 同步通信的方案。
以上同步方案,一次同步調用性能分析,基本在 5ms 以內:
關鍵實現代碼如下:
@Keep public?class?NativeLongScreenshotJni?implements?Serializable?{static?{System.loadLibrary("flutter_longscreenshot");}public?static?native?void?nativeCanScrollVertically(int?direction,?boolean?startScreenshot,int?callbackId);public?static?native?void?nativeGetScrollY(int?screenWidth,?int?callbackId);public?static?native?void?nativeScrollBy(int?screenWidth,?int?x,?int?y);public?static?boolean?canScrollVertically(final?int?direction,final?boolean?startScreenshot)?{FlutterLongScreenshotCallbacks.AwaitCallback?callback?=FlutterLongScreenshotCallbacks.newCallback();nativeCanScrollVertically(direction,?startScreenshot,?callback.id());int?result?=?callback.waitCallback().getResult();return?result?==?1;}public?static?int?getScrollY(final?int?screenWidth)?{FlutterLongScreenshotCallbacks.AwaitCallback?callback?=FlutterLongScreenshotCallbacks.newCallback();nativeGetScrollY(screenWidth,?callback.id());//?waitCallback?同步等待?C++?調用?FlutterLongScreenshotCallbacks.handleDartCallint?result?=?callback.waitCallback().getResult();return?result;}public?static?void?scrollBy(int?screenWidth,?int?x,?int?y)?{nativeScrollBy(screenWidth,?x,?y);} }@Keep public?class?FlutterLongScreenshotCallbacks?implements?Serializable?{public?static?AwaitCallback?newCallback()?{AwaitCallback?callback?=?new?AwaitCallback();CALLBACKS.put(callback.id(),?callback);return?callback;}//?C++?DART_EXPORT?void?resultCallback(int?callbackId,?int?result)?反射調用public?static?void?handleDartCall(int?id,?int?result)?{AwaitCallback?callback?=?CALLBACKS.get(id);if?(callback?!=?null)?{CALLBACKS.remove(id);callback.release(result);}}private?static?final?SparseArray<AwaitCallback>?CALLBACKS?=?new?SparseArray<>();@Keeppublic?static?class?AwaitCallback?{public?static?final?int?RESULT_ERR?=?-1;private?final?CountDownLatch?mLatch?=?new?CountDownLatch(1);private?int?mResult?=?RESULT_ERR;public?int?id()?{return?hashCode();}public?AwaitCallback?waitCallback()?{try?{mLatch.await(100,?TimeUnit.MILLISECONDS);}?catch?(Throwable?e)?{e.printStackTrace();}return?this;}public?void?release(int?result)?{mResult?=?result;mLatch.countDown();}public?int?getResult()?{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");}} }class?NativeLongScreenshot?extends?Object?{...late?final?NativeLongScreenshotLibrary?_nativeLibrary;late?final?ReceivePort?_receivePort;late?final?StreamSubscription?_subscription;NativeLongScreenshot()?{..._nativeLibrary?=?initLibrary();_receivePort?=?ReceivePort();var?nativeInited?=?_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)?{List<dynamic>?args?=?inArgs;String?method?=?args[0];switch?(method)?{case?'canScrollVertically':?{int?direction?=?args[1];bool?startScreenshot?=?args[2];int?callbackId?=?args[3];final?bool?canScroll?=?canScrollVertically(direction,?startScreenshot);int?result?=?canScroll???1?:?0;_nativeLibrary.resultCallback(callbackId,?result);}?break;case?'getScrollY':?{int?nativeScreenWidth?=?args[1];int?callbackId?=?args[2];int?result?=?getScrollY(nativeScreenWidth);_nativeLibrary.resultCallback(callbackId,?result);}?break;case?'scrollBy':?{int?nativeScreenWidth?=?args[1];int?nativeX?=?args[2];int?nativeY?=?args[3];scrollBy(nativeY,?nativeScreenWidth);}?break;}} }總結
完成國內主要機型適配,現在線上幾乎不再有用戶反饋 Flutter 頁面不支持長截屏。閑魚 Android 用戶已經能用系統長截屏能力,分享自己喜歡的商品、圈子內容,賣家能使用一張圖片推廣自己的全部商品,買家能幫助家里不會用 App 的老人找商品。
面對系統功能適配,業務 App 側也并不是完全束手無策。通過以下過程便有可能找到解決之道:
??合理猜想(系統模塊會調用業務視圖接口)
??工具輔助分析和驗證(ASM 代碼 hook,日志輸出)
??源碼查找和截圖(代碼查找和反編譯)
??發散思考(ControlView 頂替 Flutter 容器,瞞天過海)
??方案實現(業務無侵入,一次實現全部業務頁面適配)
總結
以上是生活随笔為你收集整理的Flutter 长截屏适配 Miui 系统,一点都不难的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 生活感悟——和尾号990的滴滴师傅的聊天
- 下一篇: springboot多环境加载yml和l