美文网首页
Android登录拦截场景-探讨多种实现方式

Android登录拦截场景-探讨多种实现方式

作者: 我爱田Hebe | 来源:发表于2022-09-16 10:28 被阅读0次

    前言

    本文是基于之前的系列文章做的一个合集,精简之后整理为一篇长文供大家参考。合集的入口在此。合集内部有每种方案的详细使用手册,大家可以对照本文参考使用。

    登录拦截与放行是大部分App开发都会遇到的一个场景,如果你的App有游客模式,但是部分高级功能需要登录之后才能使用。

    那么我们就需要在用户点击这个操作的时候校验是否登录,当登录完成之后再跳转到指定的页面或弹窗。如果这些入口很多的话,那么我们就需要到处写这些逻辑。比较初级的用法是使用消息总线,当登录完成之后发送对应key消息,然后去完成对应key的事件。

    有没有一种更简单的方式,集中统一方便的管理登录拦截再放行这一个场景。

    下面我们一起来看一看具体的方案。

    一、方法池方案

    本质就是把你要拦截执行的方法作为一个对象,存入到一个方法池列表中,使用完之后再自动释放掉。(需要注意生命周期,当页面Destory的时候要主动释放)

    先定义方法对象

    public abstract class IFunction {
    
        public String functionName;
    
        public IFunction(String functionName) {
    
            this.functionName = functionName;
        }
    
        protected abstract void function();
    
    }
    

    方法池:

    public class FunctionManager {
    
        private static FunctionManager functionManager;
    
        private static HashMap<String, IFunction> mFunctionMap;
    
        public FunctionManager() {
            mFunctionMap = new HashMap<>();
    
        }
    
        public static FunctionManager get() {
            if (functionManager == null) {
                functionManager = new FunctionManager();
            }
            return functionManager;
        }
    
        /**
         * 添加方法
         */
        public FunctionManager addFunction(IFunction function) {
            if (mFunctionMap != null) {
                mFunctionMap.put(function.functionName, function);
            }
            return this;
        }
    
        /**
         * 执行方法
         */
        public void invokeFunction(String key) {
            if (TextUtils.isEmpty(key)) {
                return;
            }
            if (mFunctionMap != null) {
                IFunction function = mFunctionMap.get(key);
    
                if (function != null) {
                    function.function();
                    //用完移除掉
                    removeFunction(key);
                } else {
                    try {
                        throw new RuntimeException("function not found");
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        /**
         * 使用之后移除相关的缓存
         */
        public void removeFunction(String key) {
            if (mFunctionMap != null) {
                mFunctionMap.remove(key);
            }
        }
    
    }
    
    

    使用的时候也是非常简单

        private fun checkLogin() {
            if (SP().getString(Constants.KEY_TOKEN, "").checkEmpty()) {
    
                FunctionManager.get().addFunction(object : IFunction("gotoProfilePage") {
                    override fun function() {
                        gotoProfilePage()
                    }
                })
    
                gotoLoginPage()
    
            } else {
    
                gotoProfilePage()
            }
        }
    
    

    登录完成之后,我们需要手动调用

        //方法池的方式
        FunctionManager.get().invokeFunction("gotoProfilePage")
    

    这样就可以触发回调完成登录拦截的功能了。

    如果想对游客的校验也做一个封装,也可以在 FunctionManager 中定义好,可以自由扩展。

    二、消息回调方案

    其本质是通过消息总线实现,通过管理类发送消息,接收消息,通过回调的方式去执行拦截的方法。相比前者,他的好处是不需要我们处理生命周期。

    我们指定好统一的消息key之后,都通过这个key来处理登录完成的逻辑

    
    public class FunctionManager {
    
        private static FunctionManager functionManager;
    
        private static HashMap<String, Function> mFunctionMap;
    
        public FunctionManager() {
            mFunctionMap = new HashMap<>();
    
        }
    
        public static FunctionManager get() {
            if (functionManager == null) {
                functionManager = new FunctionManager();
            }
            return functionManager;
        }
    
        public void addLoginCallback(LifecycleOwner owner, ILoginCallback callback) {
            LiveEventBus.get("login", Boolean.class).observe(owner, aBoolean -> {
                if (aBoolean != null && aBoolean) {
                    callback.callback();
                }
            });
        }
    
        public interface ILoginCallback {
            void callback();
        }
    
        public void finishLogin() {
            LiveEventBus.get("login").post(true);
        }
    }
    
    
     FunctionManager.get().addLoginCallback(this) {
                gotoProfilePage()
            }
    

    登录完成之后,我们需要手动调用

        //方法池的方式
        FunctionManager.get().finishLogin()
    

    这样就可以触发回调完成登录拦截的功能了。

    和方法池的方式又异曲同工之妙。

    三、Intent的方案

    其实不使用一些容器,我们原始的使用Intent也是可以实现逻辑的。

    原理是通过登录成功之后startActivity启动自己的页面,然后通过 onNewIntent 拿到对应的操作意图去执行对应的操作。

    只是需要我们把原始的意图封装到启动自己的Intent中。

    
        fun switchPage3() {
                f (!LoginManager.isLogin()) {
                val intent = Intent(mActivity, Demo3Activity::class.java)
                intent.addCategory(switch_tab3)
    
                gotoLoginPage(intent)
    
            } else {
                    switchFragment(3)
            }
        }
    
        //把原始意图当参数传递
        fun gotoLoginPage(targetIntent: Intent) {
            val intent = Intent(mActivity, LoginDemoActivity::class.java)
            intent.putExtra("targetIntent", targetIntent)
            startActivity(intent)
        }
    
        //通过这样的方式可以拿到携带的数据
        override fun onNewIntent(intent: Intent) {
            super.onNewIntent(intent)
            YYLogUtils.w("收到newintent:" + intent.toString())
            val categories = intent.categories
    
            when (categories.take(1)[0]) {
                switch_tab1 -> {
                    switchFragment(1)
                }
                switch_tab2 -> {
                    switchFragment(2)
                }
                switch_tab3 -> {
                    switchFragment(3)
                }
            }
    
        }
    
    

    那么在Login页面登录完成之后再启动当前页面即可把携带的数据传递回来,通过newIntent就可以做对应的操作。

    四、动态代理+Hook的方案

    如果说Intent的方案还需要我们手动的处理跳转,那么此方案就是升级版,自动的拦截跳转,之后的放行方案我们还是通过 Intent 与 onNewIntent 的回调来处理。

    难点就是如何使用Hook代替Activity的启动。

    public class DynamicProxyUtils {
    
        //修改启动模式
        public static void hookAms() {
            try {
    
                Field singletonField;
                Class<?> iActivityManager;
                // 1,获取Instrumentation中调用startActivity(,intent,)方法的对象
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    // 10.0以上是ActivityTaskManager中的IActivityTaskManagerSingleton
                    Class<?> activityTaskManagerClass = Class.forName("android.app.ActivityTaskManager");
                    singletonField = activityTaskManagerClass.getDeclaredField("IActivityTaskManagerSingleton");
                    iActivityManager = Class.forName("android.app.IActivityTaskManager");
                } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    // 8.0,9.0在ActivityManager类中IActivityManagerSingleton
                    Class activityManagerClass = ActivityManager.class;
                    singletonField = activityManagerClass.getDeclaredField("IActivityManagerSingleton");
                    iActivityManager = Class.forName("android.app.IActivityManager");
                } else {
                    // 8.0以下在ActivityManagerNative类中 gDefault
                    Class<?> activityManagerNative = Class.forName("android.app.ActivityManagerNative");
                    singletonField = activityManagerNative.getDeclaredField("gDefault");
                    iActivityManager = Class.forName("android.app.IActivityManager");
                }
                singletonField.setAccessible(true);
                Object singleton = singletonField.get(null);
    
                // 2,获取Singleton中的mInstance,也就是要代理的对象
                Class<?> singletonClass = Class.forName("android.util.Singleton");
                Field mInstanceField = singletonClass.getDeclaredField("mInstance");
                mInstanceField.setAccessible(true);
    
                Method getMethod = singletonClass.getDeclaredMethod("get");
                Object mInstance = getMethod.invoke(singleton);
                if (mInstance == null) {
                    return;
                }
    
                //开始动态代理
                Object proxy = Proxy.newProxyInstance(
                        Thread.currentThread().getContextClassLoader(),
                        new Class[]{iActivityManager},
                        new AmsHookBinderInvocationHandler(mInstance));
    
                //现在替换掉这个对象
                mInstanceField.set(singleton, proxy);
    
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
        //动态代理执行类
        public static class AmsHookBinderInvocationHandler implements InvocationHandler {
    
            private Object obj;
    
            public AmsHookBinderInvocationHandler(Object rawIActivityManager) {
                obj = rawIActivityManager;
            }
    
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    
                if ("startActivity".equals(method.getName())) {
    
                    Intent raw;
                    int index = 0;
                    for (int i = 0; i < args.length; i++) {
                        if (args[i] instanceof Intent) {
                            index = i;
                            break;
                        }
                    }
    
                    //原始意图
                    raw = (Intent) args[index];
                    YYLogUtils.w("原始意图:" + raw);
    
                    //设置新的Intent-直接制定LoginActivity
                    Intent newIntent = new Intent();
                    String targetPackage = "com.guadou.kt_demo";
                    ComponentName componentName = new ComponentName(targetPackage, LoginDemoActivity.class.getName());
                    newIntent.setComponent(componentName);
    
                    YYLogUtils.w("改变了Activity启动");
    
                    args[index] = newIntent;
    
                    YYLogUtils.w("拦截activity的启动成功" + " --->");
    
                    return method.invoke(obj, args);
    
                }
    
                //如果不是拦截的startActivity方法,就直接放行
                return method.invoke(obj, args);
            }
    
        }
    }
    
    

    使用的时候我们需要启动代理,在跳转页面的时候就会自动拦截了。

        mBtnProfile.click {
    
            //启动动态代理
             DynamicProxyUtils.hookAms()
    
            gotoActivity<ProfileDemoActivity>()
        }
    

    之后的逻辑和上面的Intent方案是一样的回调处理,走 onNewIntent 里面处理。

    目前的Hook只兼容到Android12。还没有看13的源码不知道有没有变动。并且此方案只能适用于页面的跳转,有些场景比如切换Tab、ViewPager的情况下,是无法实现拦截的。

    如果不想全部的页面都拦截,大家也可以自行实现白名单的管理,只拦截部分的页面。

    但相对其他方案来说其实不是很好用,这样的自动感觉还不如全手动的Intent灵活。

    五、Java线程方案

    相对其他的方案,此方案的思路就比较清奇,利用线程的等待与恢复来实现,当我们跳转到登录页面的时候我们让线程等待,然后等待登录完成之后我们再恢复等待。

    /**
     * 登录拦截的线程管理
     */
    public class LoginInterceptThreadManager  {
    
        private static LoginInterceptThreadManager threadManager;
    
        private static final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
        private static final Handler mHandler = new Handler();
    
        private LoginInterceptThreadManager() {
        }
    
        public static LoginInterceptThreadManager get() {
            if (threadManager == null) {
                threadManager = new LoginInterceptThreadManager();
            }
    
            return threadManager;
        }
    
        /**
         * 检查是否需要登录
         */
        public void checkLogin(Runnable nextRunnable, Runnable loginRunnable) {
    
            if (LoginManager.isLogin()) {
                //已经登录
                mHandler.post(nextRunnable);
                return;
            }
    
            //如果没有登录-先去登录页面
            mHandler.post(loginRunnable);
    
            singleThreadExecutor.execute(() -> {
    
                try {
                    YYLogUtils.w("开始运行-停止");
    
                    synchronized (singleThreadExecutor) {
                        singleThreadExecutor.wait();
    
                        YYLogUtils.w("等待notifyAll完成了,继续执行");
    
                        if (LoginManager.isLogin()) {
                            mHandler.post(nextRunnable);
                        }
                    }
    
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
    
            });
    
        }
    
        public void loginFinished() {
            if (mHandler == null) return;
            if (singleThreadExecutor == null) return;
    
            synchronized (singleThreadExecutor) {
                singleThreadExecutor.notifyAll();
            }
        }
    
    }
    

    使用的时候也简单

        private fun checkLogin() {
            LoginInterceptThreadManager.get().checkLogin( {
                gotoProfilePage()
            }, {
                gotoLoginPage()
            })
        }
    
        private fun gotoLoginPage() {
            gotoActivity<LoginDemoActivity>()
        }
    
        private fun gotoProfilePage() {
            gotoActivity<ProfileDemoActivity>()
        }
    

    登录完成之后,我们需要手动调用

        //方法池的方式
        oginInterceptThreadManager.get().loginFinished()
    

    这样就可以触发回调完成登录拦截的功能了。

    六、Kotlin协程方案

    既然线程都可以,没道理协程不能使用这样的方案,协程也可以使用等待恢复的方案,还能使用协程通信的方案,开启两个协程,然后当登录完成之后去通知其中的接收协程去继续执行。

    class LoginInterceptCoroutinesManager private constructor() : DefaultLifecycleObserver, CoroutineScope by MainScope() {
    
        companion object {
            private var instance: LoginInterceptCoroutinesManager? = null
                get() {
                    if (field == null) {
                        field = LoginInterceptCoroutinesManager()
                    }
                    return field
                }
    
            fun get(): LoginInterceptCoroutinesManager {
                return instance!!
            }
        }
    
        private lateinit var mCancellableContinuation: CancellableContinuation<Boolean>
    
        fun checkLogin(loginAction: () -> Unit, nextAction: () -> Unit) {
    
            launch {
    
                if (LoginManager.isLogin()) {
                    nextAction()
                    return@launch
                }
    
                loginAction()
    
                val isLogin = suspendCancellableCoroutine<Boolean> {
    
                    mCancellableContinuation = it
    
                    YYLogUtils.w("暂停协程,等待唤醒")
                }
    
                YYLogUtils.w("已经恢复协程,继续执行")
                if (isLogin) {
                    nextAction()
                }
    
            }
        }
    
        fun loginFinished() {
    
            if (!this@LoginInterceptCoroutinesManager::mCancellableContinuation.isInitialized) return
    
            if (mCancellableContinuation.isCancelled) return
    
            mCancellableContinuation.resume(LoginManager.isLogin(), null)
    
        }
    
        override fun onDestroy(owner: LifecycleOwner) {
            YYLogUtils.w("LoginInterceptCoroutinesManager - onDestroy")
    
            mCancellableContinuation.cancel()
            cancel()
        }
    
    }
    

    使用也比较简单

           //协程的方式
            mBtnProfile2.click {
                LoginInterceptCoroutinesManager.get().checkLogin(loginAction = {
                    gotoLoginPage()
                }, nextAction = {
                    gotoProfilePage()
                })
    
            }
    

    登录完成之后,我们需要手动调用

        //方法池的方式
        oginInterceptThreadManager.get().loginFinished()
    

    这样就可以触发回调完成登录拦截的功能了。

    协程另一种方案就是通知的方式:

    class LoginInterceptCoroutinesManager private constructor() : DefaultLifecycleObserver, CoroutineScope by MainScope() {
    
        companion object {
            private var instance: LoginInterceptCoroutinesManager? = null
                get() {
                    if (field == null) {
                        field = LoginInterceptCoroutinesManager()
                    }
                    return field
                }
    
            fun get(): LoginInterceptCoroutinesManager {
                return instance!!
            }
        }
    
        private val channel = Channel<Boolean>()
    
        fun checkLogin(loginAction: () -> Unit, nextAction: () -> Unit) {
    
            launch {
    
                if (LoginManager.isLogin()) {
                    nextAction()
                    return@launch
                }
    
                loginAction()
    
                val isLogin = channel.receive()
    
                YYLogUtils.w("收到消息:" + isLogin)
    
                if (isLogin) {
                    nextAction()
                }
            }
        }
    
        fun loginFinished() {
    
            launch {
    
                async {
                    YYLogUtils.w("发送消息:" + LoginManager.isLogin())
                    channel.send(LoginManager.isLogin())
                }
    
            }
        }
    
        override fun onDestroy(owner: LifecycleOwner) {
            cancel()
        }
    
    }
    

    使用起来和暂停恢复的方案是一样样的。

    七、Aop切面方案

    除了这些方案之外,网上比较流行的就是面向切面AOP的方案。

    需要我们集成 AspectJ 框架来实现。

    使用的时候就需要定义一个自定义的注解,然后围绕这个注解做一些操作。

    //不需要回调的处理
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface Login {
    }
    

    除了注解的类

    @Aspect
    public class LoginAspect {
    
        @Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
        public void Login() {
        }
    
        @Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.LoginCallback)")
        public void LoginCallback() {
        }
    
        //带回调的注解处理
        @Around("LoginCallback()")
        public void loginCallbackJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
            YYLogUtils.w("走进AOP方法-LoginCallback()");
            Signature signature = joinPoint.getSignature();
    
            if (!(signature instanceof MethodSignature)){
                throw new RuntimeException("该注解只能用于方法上");
            }
    
            LoginCallback loginCallback = ((MethodSignature) signature).getMethod().getAnnotation(LoginCallback.class);
            if (loginCallback == null) return;
    
            //判断当前是否已经登录
            if (LoginManager.isLogin()) {
                joinPoint.proceed();
    
            } else {
                LifecycleOwner lifecycleOwner = (LifecycleOwner) joinPoint.getTarget();
    
                LiveEventBus.get("login").observe(lifecycleOwner, new Observer<Object>() {
                    @Override
                    public void onChanged(Object integer) {
                        try {
                            joinPoint.proceed();
                            LiveEventBus.get("login").removeObserver(this);
    
                        } catch (Throwable throwable) {
                            throwable.printStackTrace();
                            LiveEventBus.get("login").removeObserver(this);
                        }
                    }
                });
    
                LoginManager.gotoLoginPage();
            }
        }
    
        //不带回调的注解处理
        @Around("Login()")
        public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
            YYLogUtils.w("走进AOP方法-Login()");
            Signature signature = joinPoint.getSignature();
    
            if (!(signature instanceof MethodSignature)){
                throw new RuntimeException("该注解只能用于方法上");
            }
    
            Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
            if (login == null) return;
    
            //判断当前是否已经登录
            if (LoginManager.isLogin()) {
                joinPoint.proceed();
            } else {
                //如果未登录,去登录页面
                LoginManager.gotoLoginPage();
            }
    
        }
    }
    

    定义一个工具类来定义一些固定的方法:

    object LoginManager {
    
        @JvmStatic
        fun isLogin(): Boolean {
            val token = SP().getString(Constants.KEY_TOKEN, "")
            YYLogUtils.w("LoginManager-token:$token")
            val checkEmpty = token.checkEmpty()
            return !checkEmpty
        }
    
        @JvmStatic
        fun gotoLoginPage() {
            commContext().gotoActivity<LoginDemoActivity>()
        }
    }
    

    到这里我们就能使用AOP来拦截了。我们把需要拦截的方法使用我们的自定义注解来标记。然后我们的处理器就会对这个注解做一些围绕的操作。

        override fun init() {
    
            mBtnCleanToken.click {
                SP().remove(Constants.KEY_TOKEN)
                toast("清除成功")
            }
    
            mBtnProfile.click {
    
               //不带回调的登录方式
               gotoProfilePage2()
            }
    
        }
    
        @Login
        private fun gotoProfilePage2() {
            gotoActivity<ProfileDemoActivity>()
        }
    

    可以看到内部也是通过消息总线来执行继续操作的逻辑的,我们需要在登录完成之后发送这个通知才行。

    八、拦截器的方案

    最后一种方案是基于责任链模式的改版,自定义拦截器实现的,和默认的责任链是有些差异的。其中没有用到参数的传递。

    原理是我们定义2层拦截,一个是校验登录,一个是执行逻辑。当我们校验登录不通过的时候就会跳转到登录页面,当登录完成之后,我们继续拦截器就会走到执行逻辑。间接的完成一个登录拦截的功能。

    拦截器的定义

    object LoginInterceptChain {
    
        private var index: Int = 0
    
        private val interceptors by lazy(LazyThreadSafetyMode.NONE) {
            ArrayList<Interceptor>(2)
        }
    
        //默认初始化Login的拦截器
        private val loginIntercept = LoginInterceptor()
    
        // 执行拦截器。
        fun process() {
    
            if (interceptors.isEmpty()) return
    
            when (index) {
                in interceptors.indices -> {
                    val interceptor = interceptors[index]
                    index++
                    interceptor.intercept(this)
                }
    
                interceptors.size -> {
                    clearAllInterceptors()
                }
            }
        }
    
        // 添加一个拦截器。
        fun addInterceptor(interceptor: Interceptor): LoginInterceptChain {
            //默认添加Login判断的拦截器
            if (!interceptors.contains(loginIntercept)) {
                interceptors.add(loginIntercept)
            }
    
            if (!interceptors.contains(interceptor)) {
                interceptors.add(interceptor)
            }
    
            return this
        }
    
        //放行登录判断拦截器
        fun loginFinished() {
            if (interceptors.contains(loginIntercept) && interceptors.size > 1) {
                loginIntercept.loginfinished()
            }
        }
    
        //清除全部的拦截器
        private fun clearAllInterceptors() {
            index = 0
            interceptors.clear()
        }
    
    }
    

    校验登录的拦截器:

    /**
     * 判断是否登录的拦截器
     */
    class LoginInterceptor : BaseLoginInterceptImpl() {
    
        override fun intercept(chain: LoginInterceptChain) {
            super.intercept(chain)
    
            if (LoginManager.isLogin()) {
                //如果已经登录 -> 放行, 转交给下一个拦截器
                chain.process()
            } else {
                //如果未登录 -> 去登录页面
                LoginDemoActivity.startInstance()
            }
        }
    
        fun loginfinished() {
            //如果登录完成,调用方法放行到下一个拦截器
            mChain?.process()
        }
    }
    

    继续执行的拦截器:

    /**
     * 登录完成下一步的拦截器
     */
    class LoginNextInterceptor(private val action: () -> Unit) : BaseLoginInterceptImpl() {
    
        override fun intercept(chain: LoginInterceptChain) {
            super.intercept(chain)
    
            if (LoginManager.isLogin()) {
                //如果已经登录执行当前的任务
                action()
            }
    
            mChain?.process()
        }
    
    }
    

    使用的时候我们使用拦截器管理即可

        private fun checkLogin() {
            LoginInterceptChain.addInterceptor(LoginNextInterceptor {
                gotoProfilePage()
            }).process()
        }
    
    

    登录完成之后记得手动放行哦

        //拦截器放行
        LoginInterceptChain.loginFinished()
    

    这样就完成了登录拦截的功能了。

    总结

    本文是一个总纲或者说是总结,这里的几种方法我都只是简单的介绍了一下,具体的使用可以看看单独的文章,每一篇具体使用的方式之前都已经出了对应的文章,并附带了Demo,有兴趣的朋友可以前往查看

    总的来说实现这种方式推荐大家使用简单易于理解和集成使用的方式。例如方法池,消息通知回调,线程协程的方案,自定义拦截的方案其实都是不错的,大家自己按需选择即可。

    除开一些集成困难,有兼容性的一些方案之外,其他的这些方案都是可以用的了,剩下的我们需要考虑的就是,此方案是否有更大的内存开销,是否有内存泄露风险,需要处理页面意外关闭的情况吗?有没有降级或兜底的方案?有没有崩溃的风险?有没有重复调用的风险?等等等等。

    本文也只是基于Demo的实现,如果正式在生产上面使用的话,大家可以自行扩展一下它的健壮性。

    本文全部代码均以开源,源码在此。大家可以点个Star关注一波,有问题我会及时更新。

    好了,本期内容如有错漏的地方,希望同学们可以指出交流。如果有更好的方法,也欢迎大家评论区讨论。

    如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。

    作者:newki

    链接:https://juejin.cn/post/7143040409558581262

    相关文章

      网友评论

          本文标题:Android登录拦截场景-探讨多种实现方式

          本文链接:https://www.haomeiwen.com/subject/rikjortx.html