Androidシニアエンジニアインタビュー:Androidウィンドウメカニズムの原則のトークン検証(アプリケーションコンテキストがダイアログを表示できない理由)

概要概要

注:この記事はAndroid 10のソースコードに基づいています。簡潔にするために、ソースコードは省略できます。

今日、ナゲッツに関する分析为什么不能使用 Application Context 显示 Dialog記事を見ました。それを読んだ、作者が非常に重要なオブジェクトであるparentWindowを見落としていたので、説明するときにソースロジックを完全につなげることができなかったと感じました。Android-Windowメカニズムの原理の以前の分析を参照した後、ソースコードを読み直し、この質問を使用して、Android WMSがウィンドウを追加しているときのトークン検証のロジックを記録して、アプリケーションコンテキストができない理由を説明することにしました。ダイアログを表示するために使用されます。

Androidでは、アクティビティ以外のコンテキストを使用して通常のダイアログ(システム以外のダイアログなど)を表示することはできません。次のコードを実行すると、エラーが報告されます。

val dialog = Dialog(applicationContext)
dialog.show()

// ------- error -------
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
    at android.view.ViewRootImpl.setView(ViewRootImpl.java:840)
    at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:356)
    at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:94)
    at android.app.Dialog.show(Dialog.java:329)
    // ...

コード行を追加すると、ショーが成功する可能性があることがわかります(window?.attributes?.token割り当てられた後に呼び出す必要あることに注意してください。delayまたはView.postを使用してdialog.showを呼び出すことができます)。

val dialog = Dialog(applicationContext)
dialog.window?.attributes?.token = window?.attributes?.token
dialog.show()

次に、トークン検証に関連するロジックをソースコードの観点から分析します。このパートの内容を開始する前に、startActivityスタートアップソースコードとWindowメカニズムの原則をある程度理解しておくことをお勧めします。ここでは、関連するプロセスを整理します。Android-Activityの起動プロセスからわかるように、startActivityプロセスのトークン関連の手順は次のように簡単に説明されています。

AppプロセスはContext.startActivityメソッドを呼び出し、それをAMS(system_serverプロセス)に渡して処理を行います。ここで次のトークンの作成が行われます。さらに、開始するアクティビティが新しいプロセスの場合、system_serverはzygoteの開始新しいプロセスを作成するリクエスト。ターゲットプロセスが正常に作成された後、ロジックはAMSからターゲットアクティビティプロセスに転送されます。ターゲットプロセスのActivityThreadは、performLaunchActivityメソッドを呼び出してターゲットアクティビティインスタンスを作成します。次に、Activity.attachメソッドを呼び出します。以下のWindowManagerオブジェクトがここで作成されます。Activityのライフサイクルメソッドが次々に呼び出され、DecorViewオブジェクトがonCreateのsetContentViewに作成され、onResumeコールバックの後に完了すると、DecorViewオブジェクトはWindowManager.addViewを介して追加されます(Android-Windowメカニズムの原則、バインダーを介して呼び出され、WMSで完了を参照)。

このプロセスを一般的に理解した後(上記のプロセスは簡略化されています。startActivityのソースコードを読みたくない場合は、次の分析で使用される上記のプロセスを思い出すことができます)、その方法を見てみましょう。トークンは各段階の作業に使用されます。バインダー関連の情報については、こちらを参照してください:Android-バインダー原則シリーズ、要するに、バインダーIPCメソッドはC / Sアーキテクチャです。サーバープロセスとクライアントプロセスはバインダー参照とエージェントを保持し、プロセス間転送を行うことができます。

最後の要約部分では、このプロセスをフローチャートとして出力します。ご不明な点がございましたら、メッセージを残して訂正してください。

トークンの作成

上記のプロセスに従って、AMSから始めて、トークンがどのように作成されるかを見てみましょう。Activitystartupのソースコードを読んだ場合、ActivityStarter.startActivityメソッド(AMSスレッド)に次のコードがあることがわかります。この時点でsystem_serverプロセスにあり、WMSが同じプロセスに異なるスレッドを持っている場合は、Android-init-zygoteを参照してください):

// ActivityStarter
private int startActivity(/*...*/) {
    // ...
    ActivityRecord r = new ActivityRecord(mService, callerApp, callingPid, callingUid,
            callingPackage, intent, resolvedType, aInfo, mService.getGlobalConfiguration(),
            resultRecord, resultWho, requestCode, componentSpecified, voiceSession != null,
            mSupervisor, checkedOptions, sourceRecord);
    // ...
}

次に、ActivityRecordクラスを確認します。

final class ActivityRecord extends ConfigurationContainer implements AppWindowContainerListener {

    // Binder 服务端对象
    static class Token extends IApplicationToken.Stub {
        // 持有外部 ActivityRecord 的弱引用
        private final WeakReference<ActivityRecord> weakActivity;
        private final String name;

        Token(ActivityRecord activity, Intent intent) {
            weakActivity = new WeakReference<>(activity);
            name = intent.getComponent().flattenToShortString();
        }
        // ...
    }

    ActivityRecord(/*...*/) {
        appToken = new Token(this, _intent);
        // ...
    }
}

したがって、startActivityプロセスでは、ActivityRecordオブジェクトのappTokenがインスタンス化されます。次に、ActivityStack.startActivityLockedメソッドに戻ります。

void startActivityLocked(ActivityRecord r, ActivityRecord focusedTopActivity,
        boolean newTask, boolean keepCurTransition, ActivityOptions options) {
    // ...
    r.createWindowContainer();
    // ...
}

// ActivityRecord
void createWindowContainer() {
    mWindowContainerController = new AppWindowContainerController(taskController, appToken, /*...*/);
    // ...
}

// AppWindowContainerController
public AppWindowContainerController(TaskWindowContainerController taskController, IApplicationToken token, /*...*/) {
    atoken = createAppWindow(mService, token, /*...*/);
}

AppWindowToken createAppWindow(WindowManagerService service, IApplicationToken token, /*...*/) {
    return new AppWindowToken(service, token,  /*...*/);
}

// AppWindowToken --> WindowToken
AppWindowToken(WindowManagerService service, IApplicationToken token,  /*...*/) {
    super(service, token != null ? token.asBinder() : null, TYPE_APPLICATION, /*...*/);
    appToken = token;
    mVoiceInteraction = voiceInteraction;
    mFillsParent = fillsParent;
    mInputApplicationHandle = new InputApplicationHandle(this);
}

// WindowToken
WindowToken(WindowManagerService service, IBinder _token, int type, /*...*/) {
    super(service);
    token = _token;
    windowType = type;
    mPersistOnEmpty = persistOnEmpty;
    mOwnerCanManageAppTokens = ownerCanManageAppTokens;
    mRoundedCornerOverlay = roundedCornerOverlay;
    onDisplayChanged(dc);
}

void onDisplayChanged(DisplayContent dc) {
    dc.reParentWindowToken(this);
    // ...
}

// DisplayContent
void reParentWindowToken(WindowToken token) {
    // ...
    addWindowToken(token.token, token);
}

private void addWindowToken(IBinder binder, WindowToken token) {
    // HashMap<IBinder, WindowToken> mTokenMap
    // key--ActivityRecord.Token(IApplicationToken.Stub); value--WindowToken
    mTokenMap.put(binder, token);
    // ...
}

上記のコードは重要なステップのみを示しています。クライアントプロセスがstartActivityを呼び出してアクティビティを開始し、AMS(system_serverプロセスのAMSスレッド)処理フローにIApplicationToken.Stubオブジェクトを作成していることがはっきりとわかります。これはバインダーサーバー、次にAppWindowTokenオブジェクトが作成され、DisplayContent.mTokenMapに格納されます。ここでは、AMSとWMSの両方がsystem_serverプロセスにあり、mTokenMapは後続のWMS.addWindowでトークンを検証するために使用されます(ここで、mTokenMapにスレッドセーフの問題があるかどうかについては、興味がある場合は詳細を確認できます) 。

WindowManagerオブジェクトの取得

次に、アクティビティコンテキストを使用してgetSystemServiceメソッドを呼び出すことと、アプリケーションコンテキストを使用してgetSystemServiceメソッドを呼び出すことの違いを確認します(WMSサービスの場合のみ)。

// Activity
public Object getSystemService(@ServiceName @NonNull String name) {
    if (WINDOW_SERVICE.equals(name)) {
        return mWindowManager;
    }
    // ...
}

// Application 调用的是父类 ContextImpl 的方法
// ContextImpl
public Object getSystemService(String name) {
    return SystemServiceRegistry.getSystemService(this, name);
}

// SystemServiceRegistry
registerService(Context.WINDOW_SERVICE, WindowManager.class, new CachedServiceFetcher<WindowManager>() {
    @Override
    public WindowManager createService(ContextImpl ctx) {
        return new WindowManagerImpl(ctx);
    }});

Activityで取得されるのは、Activity.attachメソッドで割り当てられるmWindowManagerオブジェクトです。Android-Activityの起動原理から、このメソッドはstartActivityプロセス中にコールバックされることがわかります(AMS処理後、バインダーを介してターゲットアクティビティを呼び出す):

// Activity
final void attach(/*...*/) {
    attachBaseContext(context);
    mWindow = new PhoneWindow(this, window, activityConfigCallback);
    mWindow.setWindowManager((WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
        mToken, mComponent.flattenToString(), (info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);
    mWindowManager = mWindow.getWindowManager();
    // ...
}

// Window
public void setWindowManager(WindowManager wm, IBinder appToken, String appName,
        boolean hardwareAccelerated) {
    mAppToken = appToken;
    mAppName = appName;
    if (wm == null) {
        wm = (WindowManager)mContext.getSystemService(Context.WINDOW_SERVICE);
    }
    mWindowManager = ((WindowManagerImpl)wm).createLocalWindowManager(this);
}

// WindowManagerImpl
public WindowManagerImpl createLocalWindowManager(Window parentWindow) {
    return new WindowManagerImpl(mContext, parentWindow);
}

public WindowManagerImpl(Context context) {
    this(context, null);
}

private WindowManagerImpl(Context context, Window parentWindow) {
    mContext = context;
    mParentWindow = parentWindow;
}

上記のソースコードから、アクティビティのコンテキストを使用してgetSystemServiceメソッドを呼び出すことと、アプリケーションのコンテキストを使用してgetSystemServiceメソッドを呼び出すことの違いは次のとおりです。アクティビティのWindowManagerオブジェクトのparentWindowは、アクティビティのPhoneWindowオブジェクトです。 、およびアプリケーションのWindowManagerオブジェクトのparentWindowがnullです。

上記のmTokenオブジェクト(これはクライアントプロセスのmTokenであり、上記のAMS側で作成されたTokenオブジェクトとは異なります!)については、Activity.attachの呼び出しを確認できます。

// ActivityThread
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
    activity.attach(appContext, this, getInstrumentation(), r.token, /*...*/);
}

mTokenオブジェクトがActivityClientRecord.tokenであることがわかります。現時点では、ターゲットアクティビティが、開始Activity解析から直接、ソースコードがこれがBinderのActivityRecord.tokenのActivityClientRecord.tokenAMSであることを認識できるプロセスであることに注意してください。エージェント固有のオブジェクト転送コードは投稿されなくなり、投稿するコードが多すぎるのは退屈に見えます。ここで確認したい場合は、前のブログを直接参照できます。

全体として、ターゲットアクティビティプロセスのmAppTokenはバインダープロキシオブジェクトであり、そのバインダーサーバーはAMSのActivityRecordのトークンオブジェクト(IApplicationToken.Stub)です。

WindowManager.addView

次に、WindowManager.addViewがDecorViewを追加するプロセスに到達しました。この時点では、Activityが開始されたばかりで、インターフェイスはまだ表示されていません。

// WindowManagerImpl
private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();

@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
    mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
}

// WindowManagerGlobal.java
public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow) {
    final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
    if (parentWindow != null) {
        // 由上面可知在Activity中的WindowManager里,parentWindow是PhoneWindow对象
        parentWindow.adjustLayoutParamsForSubWindow(wparams);
    }
    // ...
    ViewRootImpl root = new ViewRootImpl(view.getContext(), display);
    // ...
    root.setView(view, wparams, panelParentView);
}

上記のコードは、アクティビティが配置されているクライアントプロセスにあります。parentWindowは空ではないため、PhoneWindowオブジェクトであるため、Window.adjustLayoutParamsForSubWindowメソッドを確認してください。

// Window
void adjustLayoutParamsForSubWindow(WindowManager.LayoutParams wp) {
    if (wp.type >= WindowManager.LayoutParams.FIRST_SUB_WINDOW &&
        wp.type <= WindowManager.LayoutParams.LAST_SUB_WINDOW) {
        // ...
    } else if (wp.type >= WindowManager.LayoutParams.FIRST_SYSTEM_WINDOW &&
        wp.type <= WindowManager.LayoutParams.LAST_SYSTEM_WINDOW) {
        // ...
    } else {
        // 由于这是Application级别的window,因此走这个流程
        if (wp.token == null) {
            wp.token = mContainer == null ? mAppToken : mContainer.mAppToken;
        }
    }
}

LayoutParams.tokenが、クライアント上の上記のTokenオブジェクトのBinderプロキシを取得していることがわかりますここで覚えておいてください、以下が使用されます。次に、ViewRootImpl.setViewの関連ロジックを見てみましょう。

// ViewRootImpl
public ViewRootImpl(Context context, Display display) {
    mContext = context;
    // 继承于IWindow.Stub的W对象
    mWindow = new W(this);
    mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this, context);
    // ...
}

// View.AttachInfo
AttachInfo(IWindowSession session, IWindow window, Display display,
        ViewRootImpl viewRootImpl, Handler handler, Callbacks effectPlayer,
        Context context) {
    mWindow = window;
    mWindowToken = window.asBinder();
    mViewRootImpl = viewRootImpl;
    // ...
}

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    synchronized (this) {
        if (mView == null) {
            mView = view;
            mWindowAttributes.copyFrom(attrs);
            // 通过mWindowSession会调用到WMS.addWindow
            res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,
                    getHostVisibility(), mDisplay.getDisplayId(), mWinFrame,
                    mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
                    mAttachInfo.mOutsets, mAttachInfo.mDisplayCutout, mInputChannel);
            // ...
        }
    }
}

上記のView.AttachInfo構築メソッドに注意を払うことができますが、以下でも使用できます。このクラスは、Viewの添付情報を表します。次に、WMSのロジックがあります。

public int addWindow(Session session, IWindow client, int seq, LayoutParams attrs, /*...*/) {
    synchronized(mWindowMap) {
        AppWindowToken atoken = null;
        final boolean hasParent = parentWindow != null;
        // 这个逻辑先不看,在后面Dialog添加再说
        WindowToken token = displayContent.getWindowToken(hasParent ? parentWindow.mAttrs.token : attrs.token);
        // 创建WindowState实例
        final WindowState win = new WindowState(this, session, client, token, parentWindow,
            appOp[0], seq, attrs, viewVisibility, session.mUid, session.mCanAddInternalSystemWindow);
        mWindowMap.put(client.asBinder(), win);
        // ...
    }
}

ここでのクライアントはバインダーサーバーです–上記のViewRootImplのmWindow。上記のコードは関連するロジックのみを投稿しました。startActivityプロセスにWindowを追加するプロセスは、ここでのみ確認できます。WMS.addWindowメソッドのWindowToken tokenオブジェクト検査に使用されます。これは、後で説明するダイアログのクラッシュです。

Dialog.show

上記では、アクティビティの開始後にDecorViewを追加するプロセスについて大まかに説明しました。次に、Dialogがshowメソッドを呼び出した後に何が起こるか、およびトークンとアクティビティ/アプリケーションとの関係の調査を開始できます。

Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
    mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    final Window w = new PhoneWindow(mContext);
    mWindow = w;
    // ...
}

public void show() {
    onStart();
    // ...
    mDecor = mWindow.getDecorView();
    mWindowManager.addView(mDecor, l);
    mShowing = true;
}

WM.addViewメソッドも呼び出されていることがわかります。したがって、上記のWindowManagerGlobal.addViewメソッドを引き続き確認できます。次の2つの場合があります。

  • ダイアログに渡されるのはアクティビティコンテキストであり、WindowManagerのparentWindowは空ではありません
  • ダイアログに渡されるのはアプリケーションコンテキストであり、WindowManagerのparentWindowは空です

parentWindowが空であるかどうかに応じて、そのparentWindow.adjustLayoutParamsForSubWindow(wparams)メソッドを呼び出すかどうかを選択することはすでにわかっています。

// Window
void adjustLayoutParamsForSubWindow(WindowManager.LayoutParams wp) {
    if (wp.type >= WindowManager.LayoutParams.FIRST_SUB_WINDOW &&
            wp.type <= WindowManager.LayoutParams.LAST_SUB_WINDOW) {
        // 这里是普通对话框,因此走这个流程
        if (wp.token == null) {
            View decor = peekDecorView(); // 通过PhoneWindow拿到DecorView对象
            if (decor != null) {
                wp.token = decor.getWindowToken();
            }
        }
    }
    // ...
}

// View
public IBinder getWindowToken() {
    return mAttachInfo != null ? mAttachInfo.mWindowToken : null;
}

上記のgetWindowTokenメソッドは、前に見たmAttachInfo.mWindowTokenオブジェクトを返します。つまり、以前にViewRootImplで作成されたmWindowオブジェクト(バインダーサーバー)です。また、コンテキストがアプリケーションの場合、それwp.tokenはnullになります。

WMS.addView

そのため、ダイアログを表示する過程でWMSのパフォーマンスを引き続き監視します。

public int addWindow(Session session, IWindow client, int seq, LayoutParams attrs, /*...*/) {
    WindowState parentWindow = null;
    if (type >= FIRST_SUB_WINDOW && type <= LAST_SUB_WINDOW) {
        // 普通Dialog会走这个流程,获取parentWindow对象
        parentWindow = windowForClientLocked(null, attrs.token, false);
        if (parentWindow == null) {
            // parentWindow为null,返回bad
            return WindowManagerGlobal.ADD_BAD_SUBWINDOW_TOKEN;
        }
        if (parentWindow.mAttrs.type >= FIRST_SUB_WINDOW && parentWindow.mAttrs.type <= LAST_SUB_WINDOW) {
            // parentWindow为普通window,返回bad
            return WindowManagerGlobal.ADD_BAD_SUBWINDOW_TOKEN;
        }
    }
    // ...
}

final WindowState windowForClientLocked(Session session, IBinder client, boolean throwOnError) {
    WindowState win = mWindowMap.get(client);
    // ...
    return win;
}

最初にparentWindowのロジックを見てみましょう。windowForClientLockedメソッドのクライアントパラメータは、前のセクションで説明したものwp.tokenです。

  • ContextがApplicationの場合、nullの場合、WMSで返されるparentWindowもnullになり、Windowの追加は失敗し、不正なコードが返されます。
  • コンテキストはActivityの場合です。これはViewRootImplのmWindowオブジェクトのバインダープロキシです。startActivityを解析して上記のDecorViewを追加するプロセスで、mWindowMapにキーをmWindowオブジェクトプロキシとして追加したことがわかりました。値はその時に作成されたWindowStateオブジェクト。今回はparentWindowとして返されます。

次に、見下ろします。

public int addWindow(Session session, IWindow client, int seq, LayoutParams attrs, /*...*/) {
    // ...
    AppWindowToken atoken = null;
    final boolean hasParent = parentWindow != null;
    // 取parentWindow.mAttrs.token
    WindowToken token = displayContent.getWindowToken(hasParent ? parentWindow.mAttrs.token : attrs.token);
    if (token == null) {
        if (rootType >= FIRST_APPLICATION_WINDOW && rootType <= LAST_APPLICATION_WINDOW) {
            return WindowManagerGlobal.ADD_BAD_APP_TOKEN;
        }
        if (rootType == TYPE_INPUT_METHOD) {
            return WindowManagerGlobal.ADD_BAD_APP_TOKEN;
        }
    }
    // ...
}

// DisplayContent
WindowToken getWindowToken(IBinder binder) {
    // key--ActivityRecord.Token(IApplicationToken.Stub); value--WindowToken
    return mTokenMap.get(binder);
}

着信がActivityの場合、parentWindowが空ではないため、hasParent = trueであることがわかります。上記から、parentWindowのLayoutParams.tokenは、によって作成されたTokenオブジェクトのBinderプロキシを取得することがわかります。クライアント側のAMSであり、ずっと前にmTokenMapに追加されていますこのキーの要素したがって、Activityの場合、返されるトークンには値があり、その値はAMSによって作成されたAppWindowTokenオブジェクトです。

これで、最初にこのコード行をdialog.window?.attributes?.token = window?.attributes?.token後で追加した理由を知っておく必要があります。Dialogは通常のショーになります。Dialog自体のトークンを手動で設定するため、トークン値は、アクティビティの開始時に作成されるmAppToken(プロキシ)です。

例外がスローされました

上記のWMSが戻ったら、ViewRootImpl.setViewメソッドに戻ります。

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    synchronized (this) {
        if (mView == null) {
            res = mWindowSession.addToDisplay(/*...*/)
            if (res < WindowManagerGlobal.ADD_OKAY) {
                switch (res) {
                    case WindowManagerGlobal.ADD_BAD_APP_TOKEN:
                    case WindowManagerGlobal.ADD_BAD_SUBWINDOW_TOKEN:
                        throw new WindowManager.BadTokenException(
                            "Unable to add window -- token " + attrs.token
                            + " is not valid; is your activity running?");
                    // ...
                }
            }
            // ...
        }
    }
}

これを見て、最初に見たクラッシュログに何が起こったのかがようやくわかりました。

総括する

写真を使用してトークン検証プロセスを要約します(新しい要件が発生し、時間が急いでいます。フローチャートに問題がある場合は修正してください。以前の分析に問題がある場合は指摘してくださいそしてそれを修正してください!あなたがそれが良いと思うなら、あなたはもっと好きになることができます〜):

インタビューレビューノート:

この情報は、春の募集からさまざまなブログやフォーラムで公開されます。ウェブサイトでAndroid開発のための高品質な中級および上級の面接の質問を収集し、ネットワーク全体に最適なソリューションを見つけます。すべての面接の質問は、100%実際の質問+ベストアンサーです。パッケージの知識+多くの詳細。
みんながインターネットで情報を検索して学ぶ時間を節約できます。また、周りの友達と共有して一緒に学ぶこともできます。
記事のように小さなものを残しておけば無料で受け取れます〜

受け取るために私を突く:Androidオンライン残虐行為インタビューガイド超ハードコアAndroidインタビュー知識ノートAndroid開発者アーキテクトのための3000ページのコア知識ノート

「960ページのAndroid開発ノート」

「1307ページのAndroid開発インタビューコレクション」

Tencent、Baidu、Xiaomi、Ali、LeTV、Meituan、58、Cheetah、360、Sina、Sohu、その他の第一線のインターネット企業が質問にインタビューしました。この記事に記載されている知識のポイントに精通していると、最初の2ラウンドの技術面接に合格する可能性が大幅に高まります。

「Android開発関連のソースコード分析の507ページ」

JavaであろうとAndroidであろうと、プログラマーである限り、ソースコードを読まずに、APIドキュメントだけを見ると、それは表面にとどまります。これは、の確立と完全性には適していません。私たちの知識システムと実際の戦闘技術の改善。

本当に能力を発揮できるのは、主要なシステムのソースコードを読むだけでなく、さまざまな優れたオープンソースライブラリを含めて、ソースコードを直接読むことです。

情報は私のGitHubにアップロードされています;クイックスタートチャンネル:(ここをクリック)ダウンロードしてください!誠意あふれる!

ワンクリック3回連続インタビューのファン全員が成功したと聞きましたが?このブログがお役に立てば、編集者をサポートしてください

クイックスタートチャンネル:(ここをクリック)ダウンロードしてください!誠意あふれる!

Android Advanced Interviewの選択した質問、Architect Advanced Practical Document Portal:My GitHub

整理するのは簡単ではありません。役立つと感じた友達は、編集者のように、共有し、サポートするのに役立ちます〜

あなたのサポート、私のモチベーション;私はあなたにすべての明るい未来と絶え間ない申し出を望みます!

おすすめ

転載: blog.csdn.net/Androiddddd/article/details/110195969