幕思城>電商行情>多多開店>多多運營>Flutter 長截屏適配 Miui 系統(tǒng),一點都不難

    Flutter 長截屏適配 Miui 系統(tǒng),一點都不難

    2022-11-30|13:21|發(fā)布在分類 / 多多運營| 閱讀:106

    現(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. 1. 當(dāng)前頁面是否支持滾動截屏(長截屏 按鈕是否置灰)
    2. 2. 如何觸發(fā) App 長列表頁面滾動
    3. 3. 如何判斷是否已經(jīng)滾動觸底
    4. 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. 1. View visibility == View.VISIBLE
    2. 2. canScrollVertically(1) == true
    3. 3. View 在屏幕內(nèi)的寬度 >屏幕寬度/3
    4. 4. View 在屏幕內(nèi)的高度 >屏幕高度/2

    觸發(fā)視圖滾動

    1. 1. 每次滾動前,使用 canScrollVertically(1) 判斷是否向下滾動
    2. 2. 觸發(fā)滾動邏輯
    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. 滾動結(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. 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)

    關(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. 1. width >= RenderView.width/2
    2. 2. height >= RenderView.height/2
    3. 3. 類型是 RenderViewportBase
    4. 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. 1. 截屏廣播提前計算 canScrollVerticallly 并緩存結(jié)果
    2. 2. MIUI ContentPort 調(diào)用 canScrollVerticallly 直接返回最新緩存值,異步觸發(fā)計算
    3. 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)注幕 思 城。

    發(fā)表評論

    別默默看了 登錄\ 注冊 一起參與討論!

      微信掃碼回復(fù)「666