Hook AMS + APT实现集中式登录框架
作者:作者:Camellia666
来源:https://jianshu.com/p/7d8aed828f65
1, 背景
登录功能是App开发中一个很常见的功能,一般存在两种登录方式:
一种是进入应用就必须先登录才能使用(如聊天类软件)
另一种是以游客身份使用,需要登录的时候才会去登录(如商城类软件)
针对第二种的登录方式,一般都是在要跳转到需要登录才能访问的页面(以下简称 目标页面 )时通过if-else判断是否已登录,未登录则跳转到登录界面,登录成功后退回到原界面,用户继续进行操作。伪代码如下:
1if (需要登录) {
2 // 跳转到登录页面
3} else {
4 // 跳转到目标页面
5}
这中方式存在着以下几方面问题:
当项目功能逐渐庞大以后,存在大量重复的用于判断登录的代码,且判断逻辑可能分布在不同模块,维护成本很高。
增加或删除目标页面时需要修改判断逻辑,存在耦合。
跳转到登录页面,登录成功后只能退回到原界面,用户原本的意图被打断,需要再次点击才能进入目标界面(如:用户在个人中心界面点击“我的订单”按钮想要跳转到订单界面,由于没有登录就跳转到了登录界面,登录成功后返回个人中心界面,用户需要再次点击“我的订单”按钮才能进入订单界面)。
大致流程如下图所示:
login.png
针对传统登录方案存在的问题本文提出了一种 通过Hook AMS + APT实现集中式登录 方案。
首先通过Hook AMS实现集中处理判断,实现了跟业务逻辑解耦。
通过注解标记需要登录的页面,然后通过APT生成需要登录页面的集合,便于Hook中的判断。
最后在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}
Instrumentation的execStartActivity代码如下:
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, 0, null, 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);
这里本可以直接通过mInstance的Field及第一步中获取的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实现集中式登录的方案 ,对比传统方式本方案存在以下优势:
以非侵入性的方式将分散的登录判断逻辑集中处理,减少了代码量,提高了开发效率。
增加或删除目标页面时无需修改判断逻辑,只需增加或删除其对应注解即可,符合开闭原则,降低了耦合度
在用户登录成功后直接跳转到目标界面,保证了用户操作不被中断。
本方案并没有太高深的东西,只是把常用的东西整合在一起,综合运用了一下
。另外方案只是针对需要跳转页面的情况,对于判断是否登录后做其他操作的,比如弹出一个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}
推荐阅读
耗时一周,我解决了微信 Matrix 增量编译的 Bug,已提 PR
Android QMUI实战:实现APP换肤功能,并自动适配手机深色模式
公众号徐公回复黑马,获取 Android 学习视频 公众号徐公回复徐公666,获取简历模板,教你如何优化简历,走近大厂 公众号徐公回复面试,可以获得面试常见算法,剑指 ofer 题解 公众号徐公回复马士兵,可以获得马士兵学习视频一份