現(xiàn)有 App 大部分業(yè)務(wù)場景都是以長列表呈現(xiàn),為更好滿足用戶內(nèi)容分享的訴求,Android 各大廠商都在系統(tǒng)層面提供十分便捷的長截屏能力。然而我們發(fā)現(xiàn) Flutter 長列表頁面在部分 Android 手機上無法截長屏,F(xiàn)lutter 官方和社區(qū)也沒有提供框架層面的長截屏能力。
閑魚作為 Flutter 在國內(nèi)業(yè)務(wù)落地的代表作,大部分頁面都以 Flutter 承接。為了閑魚用戶也能享受廠商系統(tǒng)的長截屏能力,更好的滿足商品、社區(qū)內(nèi)容分享的訴求,閑魚技術(shù)團(tuán)隊主動做了分析和適配。
針對線上輿情做了統(tǒng)計分析,發(fā)現(xiàn)小米用戶輿情反饋量占比最多,其次少量是華為用戶。為此我們針對 Miui 長截屏功能做了適配。
這里華為、OPPO、VIVO 基于無障礙服務(wù)實現(xiàn),長截屏功能已經(jīng)適配 Flutter 頁面。這里少量用戶反饋,是因為截屏反饋小把手 PopupWindow 有可能出現(xiàn)遮擋,導(dǎo)致系統(tǒng)無法驅(qū)動長列表滾動。通過重寫 isImportantForAccessibility 便能解決。
小米長截屏解讀
操作和表現(xiàn)
小米手機可通過音量鍵+電源鍵、或頂部下拉功能菜單“截屏”,觸發(fā)截屏。經(jīng)過簡單嘗試,可以發(fā)現(xiàn),原生長列表頁面支持截長屏,原生頁面無長列表不支持,閑魚 Flutter 長列表頁面(如詳情頁、搜索結(jié)果頁)不支持。
點擊“截長屏”后,能看到長列表頁面會自動滾動,點擊結(jié)束或者觸底的時候,自動打開圖片編輯頁面,能看到生成的長截圖。那小米系統(tǒng)是如何無侵入的實現(xiàn)以下關(guān)鍵點:
- 1. 當(dāng)前頁面是否支持滾動截屏(長截屏 按鈕是否置灰)
- 2. 如何觸發(fā) App 長列表頁面滾動
- 3. 如何判斷是否已經(jīng)滾動觸底
- 4. 如何合成長截圖
系統(tǒng)源碼獲取
小米廠商能判斷前臺 App 頁面能否滾動,必然需要調(diào)用前臺 App 視圖的關(guān)鍵接口來獲取信息。編寫一個自定義 RecyclerView 列表頁面,日志輸出 RecycleView 方法調(diào)用:
已知長截屏需要調(diào)用的方法,再查看堆棧,可以看到調(diào)用方是系統(tǒng)類:miui.util.LongScreenshotUtils&ContentPort
使用低版本 miui(這里 miui8)手機,獲取對應(yīng)的代碼:/system/framework/framework.jar 或 github 查找 miui 開放代碼。
實現(xiàn)原理介紹
整體流程:查找滾動視圖 → 驅(qū)動視圖滾動 → 分段截圖→截圖內(nèi)容合并
查找滾動視圖
其中檢查條件:
- 1. View visibility == View.VISIBLE
- 2. canScrollVertically(1) == true
- 3. View 在屏幕內(nèi)的寬度 >屏幕寬度/3
- 4. View 在屏幕內(nèi)的高度 >屏幕高度/2
觸發(fā)視圖滾動
- 1. 每次滾動前,使用 canScrollVertically(1) 判斷是否向下滾動
- 2. 觸發(fā)滾動邏輯
- a. 特殊視圖: dispatchFakeTouchEvent(2);privatebooleancheckNeedFakeTouchForScroll() {
- if((this.mMainScrollView instanceof AbsListView) ||
- (this.mMainScrollView instanceof ScrollView) ||
- isRecyclerView(this.mMainScrollView.getClass()) ||
- isNestedScrollView(this.mMainScrollView.getClass())) {
- returnfalse;
- }
- 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. 滾動結(jié)束,對比 scrollY 和 mPrevScrolledY 是否相同,相同則認(rèn)為觸底,停止?jié)L動流程
生成長截圖
每次滾動后廣播,觸發(fā) mMainScrollView 局部截圖,最后生成多個 Bitmap,最后合成 File 文件。在適配 Flutter 頁面,這里并沒有差異,所以這里就不做源碼解讀(不同 Miui 版本實現(xiàn)也有所不同)。
閑魚適配方案
Flutter 長截屏不適配原因
通過分析源碼可知,F(xiàn)lutter 容器(SurfaceView/TextureView) canScrollVertically 方法并未被重寫,為此無法被找到作為 mMainScrollView。假如我們重寫 Flutter 容器,我們需要真實實現(xiàn) getScrollY 才能保證觸發(fā)滾動后 scrollY 和 mPrevScrolledY 不相等。不幸的是,getScrollY 是 final 類型,無法被繼承類重寫,為此我們無法在 Flutter 容器上做處理。
@InspectableProperty
publicfinalintgetScrollY() {
return mScrollY;
}
系統(tǒng)事件代理
轉(zhuǎn)變思路,我們并不需要讓 Flutter 容器被 Miui 系統(tǒng)識為可滾動視圖,而是讓 Flutter 接收到 Miui 系統(tǒng)指令。為此,我們構(gòu)建一個不可見、不影響交互的滾動視圖 ControlView 被 Miui 系統(tǒng)識別,并接收系統(tǒng)指令。ControlView 最后把指令傳遞給 Flutter,最終建立了 Miui 系統(tǒng)(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)
關(guān)鍵實現(xiàn)源碼如下
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();
// 截屏廣播監(jiān)聽
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;
}
}
無侵入識別滾動區(qū)域
獲取 RenderObject 根節(jié)點
使用 mixin 擴展 WidgetsFlutterBinding,進(jìn)而獲取 RenderView
關(guān)鍵實現(xiàn)源碼如下:
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. width >= RenderView.width/2
- 2. height >= RenderView.height/2
- 3. 類型是 RenderViewportBase
- 4. axis == Axis.vertical
實現(xiàn)源碼如下:
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 來間接檢測可見狀態(tài),但因為通過在屏幕內(nèi)的寬高判斷,無法檢測 Widget 被遮擋的情況。
左圖長列表沒有被遮擋,可以被操作;右圖被留言面板遮擋,事件無法傳遞到長列表,無法被操作;為此,我們模擬用戶的點擊能否被觸達(dá)來檢測 RenderViewportBase 是否被遮擋,能否用來做長截屏滾動。
特別注意的是,當(dāng) 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,為此并不在同一線程??梢园l(fā)現(xiàn),Java → Dart → Java 發(fā)生了 2 次線程切換。
使用小米 K50 測試性能,從 EventChannel 發(fā)送事件 到 MethodChannel 接收返回值,記錄耗時??梢?,首次 canScrollVertically (由截屏廣播觸發(fā))需要遞歸查找滾動組件,耗時為 10-30ms,之后耗時均在 5ms 以內(nèi)。
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
為保證在異步調(diào)用的情況下,MIUI ContentPort 下發(fā)命令均能獲取到最新值,這里做以下特殊處理
- 1. 截屏廣播提前計算 canScrollVerticallly 并緩存結(jié)果
- 2. MIUI ContentPort 調(diào)用 canScrollVerticallly 直接返回最新緩存值,異步觸發(fā)計算
- 3. MIUI ContentPort 調(diào)用 scrollBy 后,及時更新 canScrollVerticallly 和 getScrollY 緩存值
同步 FFI 通信方案
異步調(diào)用方案,在高端機且 App 任務(wù)隊列無阻塞情況下,能正確且準(zhǔn)確運行,但在低端機和 App 任務(wù)較重時,可能存在返回 ContentPort 數(shù)據(jù)非最新的情況,為此我們考慮使用 FFI 同步通信的方案。
以上同步方案,一次同步調(diào)用性能分析,基本在 5ms 以內(nèi):
關(guān)鍵實現(xiàn)代碼如下:
@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++ 調(diào)用 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) 反射調(diào)用
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 實現(xiàn)類似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) {
// 調(diào)用 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;
}
}
}總結(jié)
完成國內(nèi)主要機型適配,現(xiàn)在線上幾乎不再有用戶反饋 Flutter 頁面不支持長截屏。閑魚 Android 用戶已經(jīng)能用系統(tǒng)長截屏能力,分享自己喜歡的商品、圈子內(nèi)容,賣家能使用一張圖片推廣自己的全部商品,買家能幫助家里不會用 App 的老人找商品。
面對系統(tǒng)功能適配,業(yè)務(wù) App 側(cè)也并不是完全束手無策。通過以下過程便有可能找到解決之道:
- • 合理猜想(系統(tǒng)模塊會調(diào)用業(yè)務(wù)視圖接口)
- • 工具輔助分析和驗證(ASM 代碼 hook,日志輸出)
- • 源碼查找和截圖(代碼查找和反編譯)
- • 發(fā)散思考(ControlView 頂替 Flutter 容器,瞞天過海)
- • 方案實現(xiàn)(業(yè)務(wù)無侵入,一次實現(xiàn)全部業(yè)務(wù)頁面適配)
這個問題還有疑問的話,可以加幕.思.城火星老師免費咨詢,微.信號是為: msc496。
難題沒解決?加我微信給你講!【僅限淘寶賣家交流運營知識,非賣家不要加我哈】>
推薦閱讀:
速賣通用什么收款?如何設(shè)置收款方式?
淘寶刷訪客平臺有哪些?怎么刷比較安全?
快手極速回款有什么優(yōu)勢?怎么使用
更多資訊請關(guān)注幕 思 城。
別默默看了 登錄\ 注冊 一起參與討論!