Weixin Official Accounts Platform

前外交部副部长傅莹:一旦中美闹翻,有没有国家会站在中国一边

终于找到了高清版《人间中毒》,各种姿势的图,都能看

去泰国看了一场“成人秀”,画面尴尬到让人窒息.....

2017年受难周每日默想经文(值得收藏!)

生成图片,分享到微信朋友圈

自由微信安卓APP发布,立即下载! | 提交文章网址
查看原文

Hook AMS + APT实现集中式登录框架

徐公 2022-04-23

作者:作者:Camellia666

来源:https://jianshu.com/p/7d8aed828f65

1, 背景

登录功能是App开发中一个很常见的功能,一般存在两种登录方式:

  • 一种是进入应用就必须先登录才能使用(如聊天类软件)

  • 另一种是以游客身份使用,需要登录的时候才会去登录(如商城类软件)

针对第二种的登录方式,一般都是在要跳转到需要登录才能访问的页面(以下简称 目标页面 )时通过if-else判断是否已登录,未登录则跳转到登录界面,登录成功后退回到原界面,用户继续进行操作。伪代码如下:

1if (需要登录) {
2    // 跳转到登录页面
3else {
4    // 跳转到目标页面
5}

这中方式存在着以下几方面问题:

  1. 当项目功能逐渐庞大以后,存在大量重复的用于判断登录的代码,且判断逻辑可能分布在不同模块,维护成本很高。

  2. 增加或删除目标页面时需要修改判断逻辑,存在耦合。

  3. 跳转到登录页面,登录成功后只能退回到原界面,用户原本的意图被打断,需要再次点击才能进入目标界面(如:用户在个人中心界面点击“我的订单”按钮想要跳转到订单界面,由于没有登录就跳转到了登录界面,登录成功后返回个人中心界面,用户需要再次点击“我的订单”按钮才能进入订单界面)。

大致流程如下图所示:

login.png

针对传统登录方案存在的问题本文提出了一种 通过Hook AMS + APT实现集中式登录 方案。

  1. 首先通过Hook AMS实现集中处理判断,实现了跟业务逻辑解耦。

  2. 通过注解标记需要登录的页面,然后通过APT生成需要登录页面的集合,便于Hook中的判断。

  3. 最后在Hook AMS时将原意图放入登录页面的意图中,登录页面登录成功后可以获取到原意图,实现了继续用户原意图的目的。

本方案能达到的业务流程如下:

hook_login.png

1, 集中处理

这里借鉴插件化的思路通过Hook AMS实现拦截并统一处理的目的

1.1 分析Activity启动过程

了解Activity启动过程的应该都知道Activity中的startActivity()最终会进入Instrumentation

 1// Activity.java
2@Override
3public void startActivityForResult(
4        String who, Intent intent, int requestCode, @Nullable Bundle options) 
{
5    ...
6    Instrumentation.ActivityResult ar =
7        mInstrumentation.execStartActivity(
8            this, mMainThread.getApplicationThread(), mToken, who,
9            intent, requestCode, options);
10    ...
11}

InstrumentationexecStartActivity代码如下:

 1public ActivityResult execStartActivity(
2    Context who, IBinder contextThread, IBinder token, String target,
3    Intent intent, int requestCode, Bundle options) 
{
4    ...
5    try {
6        ...
7        int result = ActivityManagerNative.getDefault()
8            .startActivity(whoThread, who.getBasePackageName(), intent,
9                    intent.resolveTypeIfNeeded(who.getContentResolver()),
10                    token, target, requestCode, 0null, options);
11        checkStartActivityResult(result, intent);
12    } catch (RemoteException e) {
13        throw new RuntimeException("Failure from system", e);
14    }
15    return null;
16}

其中调用了ActivityManagerNative.getDefault()startActivity(),那么此处getDefault()获取到的是什么?接着看代码:

 1/**
2 * Retrieve the system's default/global activity manager.
3 */

4static public IActivityManager getDefault() {
5    // step 1
6    return gDefault.get();
7}
8
9// step 2
10private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
11    protected IActivityManager create() {
12        // step 5
13        IBinder b = ServiceManager.getService("activity");
14        if (false) {
15            Log.v("ActivityManager""default service binder = " + b);
16        }
17        IActivityManager am = asInterface(b);
18        if (false) {
19            Log.v("ActivityManager""default service = " + am);
20        }
21        return am;
22    }
23};
24
25public abstract class Singleton<T> {
26    private T mInstance;
27
28    protected abstract T create();
29
30    // step 3
31    public final T get() {
32        synchronized (this) {
33            if (mInstance == null) {
34                // step 4
35                mInstance = create();
36            }
37            return mInstance;
38        }
39    }
40}

gDefault是一个Singleton<IActivityManager>类型的静态常量,它的get()方法返回的是Singleton类中的private T mInstance;,这个mInstance的创建又是在gDefault实例化时通过create()方法实现。

这里代码有点绕,根据上面代码注释的step1 ~ 5,应该能理清楚:gDefault.get()获取到的mInstance实例就是ActivityManagerService(AMS)实例。

由于gDefault是一个静态常量,因此可以通过反射获取到它的实例,同时它是Singleton类型的,因此可以获取到其中的mInstance

到这里你应该能明白接下来要干什么了吧,没错就是Hook AMS。

1.2 Hook AMS

本文以android 6.0代码为例。注:8.0以下实现方式是相同的,8.0和9.0实现相同,10.0到12.0方式是一样的。

这里涉及到反射及动态代理的姿势,请自行了解。

1,获取gDefault实例

1Class<?> activityManagerNative = Class.forName("android.app.ActivityManagerNative");
2Field singletonField = activityManagerNative.getDeclaredField("gDefault");
3singletonField.setAccessible(true);
4// 获取gDefault实例
5Object singleton = singletonField.get(null);

2,获取Singleton中的mInstance

1Class<?> singletonClass = Class.forName("android.util.Singleton");
2Field mInstanceField = singletonClass.getDeclaredField("mInstance");
3mInstanceField.setAccessible(true);
4/* Object mInstance = mInstanceField.get(singleton); */
5Method getMethod = singletonClass.getDeclaredMethod("get");
6Object mInstance = getMethod.invoke(singleton);

这里本可以直接通过mInstanceField及第一步中获取的gDefault实例反射得到mInstance实例,但是实测发现在Android
10以上无法获取,不过还好可以通过Singleton中的get()方法可以获取到其实例。

3,获取要动态代理的Interface

1Class<?> iActivityManagerClass = Class.forName("android.app.IActivityManager");

4,创建一个代理对象

1Object proxyInstance = Proxy.newProxyInstance(context.getClassLoader(), new Class[]{iActivityManagerClass},
2        (proxy, method, args) -> {
3            if (method.getName().equals("startActivity") && !isLogin()) {
4                // 拦截逻辑
5            }
6            return method.invoke(mInstance, args);
7        });

5,用代理对象替换原mInstance对象

1mInstanceField.set(singleton, proxyInstance);

6,兼容性

针对8.0以下,8.0到9.0,10.0到12.0进行适配,可以兼容各个系统版本。

至此已经实现了对AMS的Hook,只需要在代理中判断当前要启动的Activity是否需要登录,然后跳转到登录即可。

但是此时出现了一个问题,这里如何判断哪些Activity需要登录的?最简单的方式就是写死,如下:

1// 获取要启动的Activity的全类名。
2String intentName = xxx
3if (intentName.equals("aaaActivity")
4    || intentName.equals("bbbActivity")
5    ...
6    || intentName.equals("xxxActivity"))
{
7    // 去登陆
8}

这样的代码存在着耦合,添加删除目标Activity都需要改这里。

接下来就是通过APT实现解耦的方案。

2, APT实现解耦

APT就不多说了,就是注解处理器,很多流行框架都在用它,如果你不了解请自行了解。

首先定义注解,然后给目标Activity加上注解就相当于打了个标记,接着通过APT找到打了这些标记的Activity,将其全类名保存起来,最后在需要使用的地方通过反射调用即可。

2.1,定义注解

 1// 目标页面注解
2@Target(ElementType.TYPE)
3@Retention(RetentionPolicy.SOURCE)
4public @interface RequireLogin {
5    // 需要登录的Activity加上该注解
6}
7
8// 登录页面注解
9@Target(ElementType.TYPE)
10@Retention(RetentionPolicy.RUNTIME)
11public @interface LoginActivity {
12    // 给登录页面加上该注解,方便在Hook中直接调用
13}
14
15// 判断是否登录方法的注解
16@Target(ElementType.METHOD)
17@Retention(RetentionPolicy.RUNTIME)
18public @interface JudgeLogin {
19    // 给判断是否登录的方法添加注解,需要是静态方法。
20}

2.2,注解处理器

这里就不贴代码了,重点是思路:

1,获取所有添加了RequireLogin注解的Activity,存入一个集合中

2,通过JavaPoet创建一个Class

3,在其中添加方法,返回1中集合里Activity的全类名的List

最终通过APT生成的类文件如下:

 1package me.wsj.login.apt;
2
3public class AndLoginUtils {
4    // 需要登录的Activity的全类名集合
5    public static List<String> getNeedLoginList() {
6        List<String> result = new ArrayList<>();
7        result.add("me.wsj.andlogin.activity.TargetActivity1");
8        result.add("me.wsj.andlogin.activity.TargetActivity2");
9        return result;
10    }
11
12    // 登录Activity的全类名
13    public static String getLoginActivity() {
14        return "me.wsj.andlogin.activity.LoginActivity";
15    }
16
17    // 判断是否登录的方法全类名
18    public static String getJudgeLoginMethod() {
19        return "me.wsj.andlogin.activity.LoginActivity#checkLogin";
20    }
21}

2.3,反射调用

在动态代理的InvocationHandler中通过反射获取

 1new InvocationHandler() {
2    @Override
3    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
4        if (method.getName().equals("startActivity") && !isLogin()) {
5            // 目标Activity全类名
6            String intentName = xxx;
7            if (isRequireLogin(intentName)) {
8                // 该Activity需要登录,跳转到登录页面
9            }
10        }
11        return null;
12    }
13}
14
15/**
16 * 该activity是否需要登录
17 *
18 * @param activityName
19 * @return
20 */

21private static boolean isRequireLogin(String activityName) {
22    if (requireLoginNames.size() == 0) {
23        // 反射调用apt生成的方法
24        try {
25            Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
26            Method getNeedLoginListMethod = NeedLoginClazz.getDeclaredMethod("getRequireLoginList");
27            getNeedLoginListMethod.setAccessible(true);
28            requireLoginNames.addAll((List<String>) getNeedLoginListMethod.invoke(null));
29            Log.d("HootUtil""size" + requireLoginNames.size());
30        } catch (Exception e) {
31            e.printStackTrace();
32        }
33    }
34    return requireLoginNames.contains(activityName);
35}

2.4,其他

实现了判断目标页面的解耦,同样的方式也可以实现跳转登录及判断是否登录的解耦。

1,跳转登录页面

前面定义了LoginActivity()注解,APT也生成了getLoginActivity()方法,那就可以反射获取到配置的登录Activity,然后创建新的Intent,替换掉原Intent,进而实现跳转到登录页面。

 1if (需要跳转到登录) {
2    Intent intent = new Intent(context, getLoginActivity());
3    // 然后需要将该intent替换掉原intent接口
4}
5
6/**
7 * 获取登录activity
8 *
9 * @return
10 */

11private static Class<?> getLoginActivity() {
12    if (loginActivityClazz == null) {
13        try {
14            Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
15            Method getLoginActivityMethod = NeedLoginClazz.getDeclaredMethod("getLoginActivity");
16            getLoginActivityMethod.setAccessible(true);
17            String loginActivity = (String) getLoginActivityMethod.invoke(null);
18            loginActivityClazz = Class.forName(loginActivity);
19        } catch (Exception e) {
20            e.printStackTrace();
21        }
22    }
23    return loginActivityClazz;
24}

2,判断是否登录

同理为了实现对判断是否登录的解耦,在判断是否能登录的方法上添加一个JudgeLogin注解,就可以在Hook中反射调用判断。当然这里也可以通过添加回调的方式实现。

2.5,小结

通过APT实现了对判断是否登录、判断哪些页面需要登录及跳转登录的解耦。

此时面临着最后一个问题,虽然前面已经实现了拦截并跳转到了登录页面,但是登录完成后再返回到原页面看似合理,实则不XXXX(词穷了,自行脑补😂),用户的意图被打断了。

接着就看看如何在登录成功后继续用户意图。

3, 继续用户意图

由于Intent实现了Parcelable接口,因此可以将它作为一个Intent的Extra参数传递。在Hook过程中可以获取原始Intent,因此只需在Hook中将用户的原始意图Intent作为一个附加参数存入跳转登录的Intent中,然后在登录页面获取到这个参数,登录成功后跳转到这个原始Intent即可。

1,传递原始意图

在动态代理中先拿到原始Intent,然后将它作为参数存入新的Intent中

 1new InvocationHandler() {
2    @Override
3    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
4        if (method.getName().equals("startActivity") && !isLogin()) {
5            // 目标Activity全类名
6            Intent originIntent = xxx;
7            String intentName = xxx;
8            if (isRequireLogin(intentName)) {
9                // 该Activity需要登录,跳转到登录页面
10                Intent intent = new Intent(context, getLoginActivity());
11                intent.putExtra(Constant.Hook_AMS_EXTRA_NAME, originIntent);
12                // 然后替换原Intent
13                ...
14            }
15        }
16        return null;
17    }
18}

2,获取原始意图并跳转

在登录页面,登录成功后判断其intent中是否有特定键值的附加数据,如果有则直接用它作为意图启动新页面,实现了继续用户意图的目的;

 1@LoginActivity
2class LoginActivity : AppCompatActivity() {
3
4    override fun onCreate(savedInstanceState: Bundle?) {
5        super.onCreate(savedInstanceState)
6
7        ...
8        binding.btnLogin.setOnClickListener {
9            // 登录成功了
10            var targetIntent = intent.getParcelableExtra<Intent>(AndLogin.TARGET_ACTIVITY_NAME)
11            // 如果存在targetIntent则启动目标intent
12            if (targetIntent != null) {
13                startActivity(targetIntent)
14            }
15            finish()
16        }
17    }
18
19    companion object {
20        // 该方法用于返回是否登录
21        @JudgeLogin
22        @JvmStatic
23        fun checkLogin()Boolean {
24            return SpUtil.isLogin()
25        }
26    }
27}

如上所示,如果可以在当前Intent中获取到Hook时保存的数据,则说明存在目标Intent,只需将其启动即可。

看一下最终效果:

preview.gif

4, ARouter方案

熟悉ARouter的都知道,它有一个拦截器的东西,可以在跳转前做拦截操作。如下:

 1@Interceptor(name = "login", priority = 1)
2public class LoginInterceptorImpl implements IInterceptor {
3    @Override
4    public void process(Postcard postcard, InterceptorCallback callback) {
5        ...
6        if (isLogin) { // 已经登录不拦截
7            callback.onContinue(postcard);
8        } else {  // 未登录则拦截
9            // callback.onInterrupt(null);
10        }
11    }
12
13    @Override
14    public void init(Context context) {
15    }
16}

实现IInterceptor接口并添加Interceptor注解即可在路由跳转时实现拦截。

了解其原理的话可知:ARouter也只是在启动Activity前提供了拦截判断的时机,相当于本方案的第一步(Hook
AMS)操作,后续实现解耦以及继续用户意图操作还需要自己实现。

5, 总结

本文提出了一种 通过Hook AMS + APT实现集中式登录的方案 ,对比传统方式本方案存在以下优势:

  1. 以非侵入性的方式将分散的登录判断逻辑集中处理,减少了代码量,提高了开发效率。

  2. 增加或删除目标页面时无需修改判断逻辑,只需增加或删除其对应注解即可,符合开闭原则,降低了耦合度

  3. 在用户登录成功后直接跳转到目标界面,保证了用户操作不被中断。

本方案并没有太高深的东西,只是把常用的东西整合在一起,综合运用了一下
。另外方案只是针对需要跳转页面的情况,对于判断是否登录后做其他操作的,比如弹出一个Toast这样的操作,可以通过AspectJ等来实现。

项目地址:https://github.com/wdsqjq/AndLogin

最后,本方案提供了远程依赖,使用startup实现了无侵入初始化,使用方式如下:

1,添加依赖

 1allprojects {
2    repositories {
3        maven { url 'https://www.jitpack.io' }
4    }
5}
6
7
8dependencies {
9    implementation 'com.github.wdsqjq.AndLogin:lib:1.0.0'
10    kapt 'com.github.wdsqjq.AndLogin:apt_processor:1.0.0'
11}

2,给需要登录的Activity添加注解

1@RequireLogin
2class TargetActivity1 : AppCompatActivity() {
3    ...
4}
5
6@RequireLogin
7class TargetActivity2 : AppCompatActivity() {
8    ...
9}

3,给登录Activity添加注解

1@LoginActivity
2class LoginActivity : AppCompatActivity() {
3    ...
4}

4,提供判断是否登录的方法

需要是一个静态方法

 1@LoginActivity
2class LoginActivity : AppCompatActivity() {
3
4    companion object {
5        // 该方法用于返回是否登录
6        @JudgeLogin
7        @JvmStatic
8        fun checkLogin()Boolean {
9            return SpUtil.isLogin()
10        }
11    }
12}


推荐阅读

RecyclerView 多样式 Item  优雅解决方案

耗时一周,我解决了微信 Matrix 增量编译的 Bug,已提 PR

Android QMUI实战:实现APP换肤功能,并自动适配手机深色模式

如果觉得对你有所帮助的话,可以关注我的微信公众号徐公,5 年中大厂工作经验。
  1. 公众号徐公回复黑马,获取 Android 学习视频
  2. 公众号徐公回复徐公666,获取简历模板,教你如何优化简历,走近大厂
  3. 公众号徐公回复面试,可以获得面试常见算法,剑指 ofer 题解
  4. 公众号徐公回复马士兵,可以获得马士兵学习视频一份




文章有问题?点此查看未经处理的缓存