Android Service 如何显示 View

336 阅读3分钟

1. 问题背景 BadTokenException

在 Service 的 onCreate 方法中 addView,运行以下代码:


WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
View view = LayoutInflater.from(this).inflate(R.layout.custom_dialog, null);
windowManager.addView(view, new WindowManager.LayoutParams());

会出现以下错误:android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?

看一下抛出错误的地方,是在 ViewRootImpl 的 setView 方法中。

2. 流程分析

重新梳理一下流程,首先调用了 WindowManagerImpl 的 addView 方法:

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

继续调用到 WindowManagerGlobal 的 addView 方法:

public void addView(View view, ViewGroup.LayoutParams params,
            Display display, Window parentWindow, int userId) {
    ...
    IWindowSession windowlessSession = null;
    // If there is a parent set, but we can't find it, it may be coming
    // from a SurfaceControlViewHost hierarchy.
    if (wparams.token != null && panelParentView == null) {
        for (int i = 0; i < mWindowlessRoots.size(); i++) {
            ViewRootImpl maybeParent = mWindowlessRoots.get(i);
            if (maybeParent.getWindowToken() == wparams.token) {
                windowlessSession = maybeParent.getWindowSession();
                break;
            }
        }
    }
    
    final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
    
    if (windowlessSession == null) {
        root = new ViewRootImpl(view.getContext(), display);
    }
    
    try {
        root.setView(view, wparams, panelParentView, userId);
    }
}

传入的 params 为 new WindowManager.LayoutParams(),看一下构造方法:

public static final int TYPE_APPLICATION        = 2;

/**
 * Identifier for this window.  This will usually be filled in for
 * you.
 */
public IBinder token = null;

public LayoutParams() {
    super(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
    type = TYPE_APPLICATION;
    format = PixelFormat.OPAQUE;
}

此时,LayoutParams 的 type 为 TYPE_APPLICATION,token 为 null。

继续看 ViewRootImpl setView 方法:

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
        int userId) {
    synchronized (this) {
        if (mView == null) {
            mWindowAttributes.copyFrom(attrs);
            attrs = mWindowAttributes;
            ...
            res = mWindowSession.addToDisplayAsUser(mWindow, mWindowAttributes,
                    getHostVisibility(), mDisplay.getDisplayId(), userId,
                    mInsetsController.getRequestedVisibleTypes(), inputChannel, mTempInsets,
                    mTempControls, attachedFrame, compatScale);
        }
       
    }
}

调用 Session 的 addToDisplayAsUser 方法,远程调用到 wms,添加 window:

@Override
public int addToDisplayAsUser(IWindow window, WindowManager.LayoutParams attrs,
        int viewVisibility, int displayId, int userId, @InsetsType int requestedVisibleTypes,
        InputChannel outInputChannel, InsetsState outInsetsState,
        InsetsSourceControl.Array outActiveControls, Rect outAttachedFrame,
        float[] outSizeCompatScale) {
    // WMS
    return mService.addWindow(this, window, attrs, viewVisibility, displayId, userId,
            requestedVisibleTypes, outInputChannel, outInsetsState, outActiveControls,
            outAttachedFrame, outSizeCompatScale);
}

调用 WMS 的 addWindow 方法:

public int addWindow(Session session, IWindow client, LayoutParams attrs, int viewVisibility,
        int displayId, int requestUserId, @InsetsType int requestedVisibleTypes,
        InputChannel outInputChannel, InsetsState outInsetsState,
        InsetsSourceControl.Array outActiveControls, Rect outAttachedFrame,
        float[] outSizeCompatScale) {
    WindowState parentWindow = null;
    // type,客户端创建默认 TYPE_APPLICATION 2
    final int type = attrs.type;
    ActivityRecord activity = null;
    final boolean hasParent = parentWindow != null;
    WindowToken token = displayContent.getWindowToken(
            hasParent ? parentWindow.mAttrs.token : attrs.token);
    final int rootType = hasParent ? parentWindow.mAttrs.type : type;
    if (token == null) {
        if (!unprivilegedAppCanCreateTokenWith(parentWindow, callingUid, type,
                rootType, attrs.token, attrs.packageName)) {
            // bad token
            return WindowManagerGlobal.ADD_BAD_APP_TOKEN;
        }
    }

}

应用层调用到 WMS,进行 View 的添加。parentWindow 初始化为 null。并且 type 为默认的 TYPE_APPLICATION,继续运行 parentWindow 值没有改变。hasParent 初始化为 false,该 View 没有父容器。token 的值为 attrs.token,为 null。

rootType 为 TYPE_APPLICATION,接着调用 unprivilegedAppCanCreateTokenWith 方法:

private boolean unprivilegedAppCanCreateTokenWith(WindowState parentWindow,
        int callingUid, int type, int rootType, IBinder tokenForLog, String packageName) {
    if (rootType >= FIRST_APPLICATION_WINDOW && rootType <= LAST_APPLICATION_WINDOW) {
        ProtoLog.w(WM_ERROR, "Attempted to add application window with unknown token "
                + "%s.  Aborting.", tokenForLog);
        return false;
    }
    if (rootType == TYPE_INPUT_METHOD) {
        ProtoLog.w(WM_ERROR, "Attempted to add input method window with unknown token "
                + "%s.  Aborting.", tokenForLog);
        return false;
    }
    if (rootType == TYPE_VOICE_INTERACTION) {
        ProtoLog.w(WM_ERROR,
                "Attempted to add voice interaction window with unknown token "
                        + "%s.  Aborting.", tokenForLog);
        return false;
    }
    if (rootType == TYPE_WALLPAPER) {
        ProtoLog.w(WM_ERROR, "Attempted to add wallpaper window with unknown token "
                + "%s.  Aborting.", tokenForLog);
        return false;
    }
    if (rootType == TYPE_QS_DIALOG) {
        ProtoLog.w(WM_ERROR, "Attempted to add QS dialog window with unknown token "
                + "%s.  Aborting.", tokenForLog);
        return false;
    }
    if (rootType == TYPE_ACCESSIBILITY_OVERLAY) {
        ProtoLog.w(WM_ERROR,
                "Attempted to add Accessibility overlay window with unknown token "
                        + "%s.  Aborting.", tokenForLog);
        return false;
    }
    if (type == TYPE_TOAST) {
        // Apps targeting SDK above N MR1 cannot arbitrary add toast windows.
        if (doesAddToastWindowRequireToken(packageName, callingUid, parentWindow)) {
            ProtoLog.w(WM_ERROR, "Attempted to add a toast window with unknown token "
                    + "%s.  Aborting.", tokenForLog);
            return false;
        }
    }
    return true;
}

满足第一个 if 条件,结果返回 false,所以 WMS 的 addWindow 方法会返回 WindowManagerGlobal.ADD_BAD_APP_TOKEN。

回去继续看 ViewRootImpl setView 方法:

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
        int userId) {
    synchronized (this) {
        if (mView == null) {
            mWindowAttributes.copyFrom(attrs);
            attrs = mWindowAttributes;
            ...
            res = mWindowSession.addToDisplayAsUser(mWindow, mWindowAttributes,
                    getHostVisibility(), mDisplay.getDisplayId(), userId,
                    mInsetsController.getRequestedVisibleTypes(), inputChannel, mTempInsets,
                    mTempControls, attachedFrame, compatScale);
                    
            if (res < WindowManagerGlobal.ADD_OKAY) {
                mAttachInfo.mRootView = null;
                mAdded = false;
                mFallbackEventHandler.setView(null);
                unscheduleTraversals();
                setAccessibilityFocus(null, null);
                switch (res) {// -1
                    case WindowManagerGlobal.ADD_BAD_APP_TOKEN:
                    case WindowManagerGlobal.ADD_BAD_SUBWINDOW_TOKEN:
                        throw new WindowManager.BadTokenException(
                                "Unable to add window -- token " + attrs.token // null
                                + " is not valid; is your activity running?");
        }
    }
}

最终会抛出 BadTokenException 异常,如文章开头所示。

3. 解决方法

根据以上分析流程可知,如果我们将 LayoutParams 的 type 值,改为系统支持的,就不会报如上错误。

WindowManager.LayoutParams.type 赋值策略:

Android 版本推荐类型常量值特性说明
Android O+TYPE_APPLICATION_OVERLAY2038官方推荐,需权限
Android N-TYPE_SYSTEM_ALERT2003传统系统弹窗
特殊场景TYPE_SYSTEM_ERROR2010最高优先级(需系统签名)

并且需要进行权限声明:<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>

实现示例:

public class MyService extends Service {
 
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (!canDrawOverlayWindows(this)) {
            requestOverlayPermission(this);
            // 可在此处添加逻辑:用户开启权限后重新启动 Service
            stopSelf(); // 关闭当前 Service,等待用户手动重启
        } else {
            // 已拥有权限,执行悬浮窗相关逻辑
            showFloatingWindow();
        }
        return START_NOT_STICKY;
    }
 
    private boolean canDrawOverlayWindows(Context context) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
            return true;
        }
        return Settings.canDrawOverlays(context);
    }
 
    private void requestOverlayPermission(Context context) {
        Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
        intent.setData(Uri.parse("package:" + context.getPackageName()));
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);
    }
 
    private void showFloatingWindow() {
        // 实现你的悬浮窗逻辑
    }
}

此处留下一个问题,如果 kill Service 进程,View 会不会消去,为什么?