Hook AMS + APT實現集中式登入框架

語言: CN / TW / HK

背景

登入功能是App開發中一個很常見的功能,一般存在兩種登入方式:

1、一種是進入應用就必須先登入才能使用(如聊天類軟體)。

2、另一種是以遊客身份使用,需要登入的時候才會去登入(如商城類軟體)。

針對第二種的登入方式,一般都是在要跳轉到需要登入才能訪問的頁面(以下簡稱目標頁面)時通過if-else判斷是否已登入,未登入則跳轉到登入介面,登入成功後退回到原介面,使用者繼續進行操作。虛擬碼如下:

if (需要登入) {
// 跳轉到登入頁面
} else {
// 跳轉到目標頁面
}

這種方式存在著以下幾方面問題:

1、當專案功能逐漸龐大以後,存在大量重複的用於判斷登入的程式碼,且判斷邏輯可能分佈在不同模組,維護成本很高。

2、增加或刪除目標頁面時需要修改判斷邏輯,存在耦合。

3、跳轉到登入頁面,登入成功後只能退回到原介面,使用者原本的意圖被打斷,需要再次點選才能進入目標介面(如:使用者在個人中心介面點選“我的訂單”按鈕想要跳轉到訂單介面,由於沒有登入就跳轉到了登入介面,登入成功後返回個人中心介面,使用者需要再次點選“我的訂單”按鈕才能進入訂單介面)。

大致流程如下圖所示:

針對傳統登入方案存在的問題本文提出了一種通過Hook AMS + APT實現集中式登入方案。

1、首先通過Hook AMS實現集中處理判斷,實現了跟業務邏輯解耦。

2、通過註解標記需要登入的頁面,然後通過APT生成需要登入頁面的集合,便於Hook中的判斷。

3、最後在Hook AMS時將原意圖放入登入頁面的意圖中,登入頁面登入成功後可以獲取到原意圖,實現了繼續使用者原意圖的目的。

本方案能達到的業務流程如下:

集中處理

這裡借鑑外掛化的思路通過Hook AMS實現攔截並統一處理的目的。

1.1 分析Activity啟動過程

瞭解Activity啟動過程的應該都知道Activity中的 startActivity() 最終會進入 Instrumentation

// Activity.java
@Override
public void startActivityForResult(
String who, Intent intent, int requestCode, @Nullable Bundle options)
{
...
Instrumentation.ActivityResult ar =
mInstrumentation.execStartActivity(
this, mMainThread.getApplicationThread(), mToken, who,
intent, requestCode, options);
...
}

Instrumentation execStartActivity 程式碼如下:

public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, String target,
Intent intent, int requestCode, Bundle options
)
{
...
try {
...
int result = ActivityManagerNative.getDefault()
.startActivity(whoThread, who.getBasePackageName(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()),
token, target, requestCode, 0, null, options);
checkStartActivityResult(result, intent);
} catch (RemoteException e) {
throw new RuntimeException("Failure from system", e);
}
return null;
}

其中呼叫了 ActivityManagerNative.getDefault() startActivity() ,那麼此處 getDefault() 獲取到的是什麼?接著看程式碼:

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

static public IActivityManager getDefault() {
// step 1
return gDefault.get();
}

// step 2
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
protected IActivityManager create() {
// step 5
IBinder b = ServiceManager.getService("activity");
if (false) {
Log.v("ActivityManager", "default service binder = " + b);
}
IActivityManager am = asInterface(b);
if (false) {
Log.v("ActivityManager", "default service = " + am);
}
return am;
}
};

public abstract class Singleton<T> {
private T mInstance;

protected abstract T create();

// step 3
public final T get() {
synchronized (this) {
if (mInstance == null) {
// step 4
mInstance = create();
}
return mInstance;
}
}
}

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例項

Class<?> activityManagerNative = Class.forName("android.app.ActivityManagerNative");
Field singletonField = activityManagerNative.getDeclaredField("gDefault");
singletonField.setAccessible(true);
// 獲取gDefault例項
Object singleton = singletonField.get(null);

2,獲取Singleton中的mInstance

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

這裡本可以直接通過 mInstance 的Field及第一步中獲取的gDefault例項反射得到 mInstance 例項,但是實測發現在Android 10以上無法獲取,不過還好可以通過Singleton中的 get() 方法可以獲取到其例項。

3,獲取要動態代理的Interface

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

4,建立一個代理物件

Object proxyInstance = Proxy.newProxyInstance(context.getClassLoader(), new Class[]{iActivityManagerClass},
(proxy, method, args) -> {
if (method.getName().equals("startActivity") && !isLogin()) {
// 攔截邏輯
}
return method.invoke(mInstance, args);
});

5,用代理物件替換原mInstance物件

mInstanceField.set(singleton, proxyInstance);

6,相容性

針對8.0以下,8.0到9.0,10.0到12.0進行適配,可以相容各個系統版本。

至此已經實現了對AMS的Hook,只需要在代理中判斷當前要啟動的Activity是否需要登入,然後跳轉到登入即可。

但是此時出現了一個問題,這裡如何判斷哪些Activity需要登入的?最簡單的方式就是寫死,如下:

// 獲取要啟動的Activity的全類名。
String intentName = xxx
if (intentName.equals("aaaActivity")
|| intentName.equals("bbbActivity")
...
|| intentName.equals("xxxActivity"))
{
// 去登陸
}

這樣的程式碼存在著耦合,新增刪除目標Activity都需要改這裡。

接下來就是通過APT實現解耦的方案。

APT實現解耦

APT就不多說了,就是註解處理器,很多流行框架都在用它,如果你不瞭解請自行了解。

首先定義註解,然後給目標Activity加上註解就相當於打了個標記,接著通過APT找到打了這些標記的Activity,將其全類名儲存起來,最後在需要使用的地方通過反射呼叫即可。

2.1,定義註解

// 目標頁面註解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface RequireLogin {
// 需要登入的Activity加上該註解
}

// 登入頁面註解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginActivity {
// 給登入頁面加上該註解,方便在Hook中直接呼叫
}

// 判斷是否登入方法的註解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface JudgeLogin {
// 給判斷是否登入的方法添加註解,需要是靜態方法。
}

2.2,註解處理器

這裡就不貼程式碼了,重點是思路:

1,獲取所有添加了 RequireLogin 註解的Activity,存入一個集合中。

2,通過JavaPoet建立一個Class。

3,在其中新增方法,返回1中集合裡Activity的全類名的List。

最終通過APT生成的類檔案如下:

package me.wsj.login.apt;

public class AndLoginUtils {
// 需要登入的Activity的全類名集合
public static List<String> getNeedLoginList() {
List<String> result = new ArrayList<>();
result.add("me.wsj.andlogin.activity.TargetActivity1");
result.add("me.wsj.andlogin.activity.TargetActivity2");
return result;
}

// 登入Activity的全類名
public static String getLoginActivity() {
return "me.wsj.andlogin.activity.LoginActivity";
}

// 判斷是否登入的方法全類名
public static String getJudgeLoginMethod() {
return "me.wsj.andlogin.activity.LoginActivity#checkLogin";
}
}

2.3,反射呼叫

在動態代理的 InvocationHandler 中通過反射獲取。

new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("startActivity") && !isLogin()) {
// 目標Activity全類名
String intentName = xxx;
if (isRequireLogin(intentName)) {
// 該Activity需要登入,跳轉到登入頁面
}
}
return null;
}
}

/**
* 該activity是否需要登入
*
* @param activityName
* @return
*/

private static boolean isRequireLogin(String activityName) {
if (requireLoginNames.size() == 0) {
// 反射呼叫apt生成的方法
try {
Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
Method getNeedLoginListMethod = NeedLoginClazz.getDeclaredMethod("getRequireLoginList");
getNeedLoginListMethod.setAccessible(true);
requireLoginNames.addAll((List<String>) getNeedLoginListMethod.invoke(null));
Log.d("HootUtil", "size" + requireLoginNames.size());
} catch (Exception e) {
e.printStackTrace();
}
}
return requireLoginNames.contains(activityName);
}

2.4,其他

實現了判斷目標頁面的解耦,同樣的方式也可以實現跳轉登入及判斷是否登入的解耦。

1,跳轉登入頁面

前面定義了 LoginActivity() 註解,APT也生成了 getLoginActivity() 方法,那就可以反射獲取到配置的登入Activity,然後建立新的Intent,替換掉原Intent,進而實現跳轉到登入頁面。

if (需要跳轉到登入) {
Intent intent = new Intent(context, getLoginActivity());
// 然後需要將該intent替換掉原intent介面
}

/**
* 獲取登入activity
*
* @return
*/

private static Class<?> getLoginActivity() {
if (loginActivityClazz == null) {
try {
Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
Method getLoginActivityMethod = NeedLoginClazz.getDeclaredMethod("getLoginActivity");
getLoginActivityMethod.setAccessible(true);
String loginActivity = (String) getLoginActivityMethod.invoke(null);
loginActivityClazz = Class.forName(loginActivity);
} catch (Exception e) {
e.printStackTrace();
}
}
return loginActivityClazz;
}

2,判斷是否登入

同理為了實現對判斷是否登入的解耦,在判斷是否能登入的方法上新增一個 JudgeLogin 註解,就可以在Hook中反射呼叫判斷。當然這裡也可以通過添加回調的方式實現。

2.5,小結

通過APT實現了對判斷是否登入、判斷哪些頁面需要登入及跳轉登入的解耦。

此時面臨著最後一個問題,雖然前面已經實現了攔截並跳轉到了登入頁面,但是登入完成後再返回到原頁面看似合理,實則不XXXX(詞窮了,自行腦補),使用者的意圖被打斷了。

接著就看看如何在登入成功後繼續使用者意圖。

繼續使用者意圖

由於Intent實現了Parcelable介面,因此可以將它作為一個Intent的Extra引數傳遞。在Hook過程中可以獲取原始Intent,因此只需在Hook中將使用者的原始意圖Intent作為一個附加引數存入跳轉登入的Intent中,然後在登入頁面獲取到這個引數,登入成功後跳轉到這個原始Intent即可。

1,傳遞原始意圖

在動態代理中先拿到原始Intent,然後將它作為引數存入新的Intent中。

new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("startActivity") && !isLogin()) {
// 目標Activity全類名
Intent originIntent = xxx;
String intentName = xxx;
if (isRequireLogin(intentName)) {
// 該Activity需要登入,跳轉到登入頁面
Intent intent = new Intent(context, getLoginActivity());
intent.putExtra(Constant.Hook_AMS_EXTRA_NAME, originIntent);
// 然後替換原Intent
...
}
}
return null;
}
}

2,獲取原始意圖並跳轉

在登入頁面,登入成功後判斷其intent中是否有特定鍵值的附加資料,如果有則直接用它作為意圖啟動新頁面,實現了繼續使用者意圖的目的。

@LoginActivity
class LoginActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

...
binding.btnLogin.setOnClickListener {
// 登入成功了
var targetIntent = intent.getParcelableExtra<Intent>(AndLogin.TARGET_ACTIVITY_NAME)
// 如果存在targetIntent則啟動目標intent
if (targetIntent != null) {
startActivity(targetIntent)
}
finish()
}
}

companion object {
// 該方法用於返回是否登入
@JudgeLogin
@JvmStatic
fun checkLogin(): Boolean {
return SpUtil.isLogin()
}
}
}

如上所示,如果可以在當前Intent中獲取到Hook時儲存的資料,則說明存在目標Intent,只需將其啟動即可。

看一下最終效果:

ARouter方案

熟悉ARouter的都知道,它有一個攔截器的東西,可以在跳轉前做攔截操作。如下:

@Interceptor(name = "login", priority = 1)
public class LoginInterceptorImpl implements IInterceptor {
@Override
public void process(Postcard postcard, InterceptorCallback callback) {
...
if (isLogin) { // 已經登入不攔截
callback.onContinue(postcard);
} else { // 未登入則攔截
// callback.onInterrupt(null);
}
}

@Override
public void init(Context context) {
}
}

實現 IInterceptor 介面並新增 Interceptor 註解即可在路由跳轉時實現攔截。

瞭解其原理的話可知:ARouter也只是在啟動Activity前提供了攔截判斷的時機,相當於本方案的第一步(Hook AMS)操作,後續實現解耦以及繼續使用者意圖操作還需要自己實現。

總結

本文提出了一種通過Hook AMS + APT實現集中式登入的方案,對比傳統方式本方案存在以下優勢:

1、以非侵入性的方式將分散的登入判斷邏輯集中處理,減少了程式碼量,提高了開發效率。

2、增加或刪除目標頁面時無需修改判斷邏輯,只需增加或刪除其對應註解即可,符合開閉原則,降低了耦合度。

3、在使用者登入成功後直接跳轉到目標介面,保證了使用者操作不被中斷。

本方案並沒有太高深的東西,只是把常用的東西整合在一起,綜合運用了一下。另外方案只是針對需要跳轉頁面的情況,對於判斷是否登入後做其他操作的,比如彈出一個Toast這樣的操作,可以通過AspectJ等來實現。

專案地址

https://github.com/wdsqjq/AndLogin

最後,本方案提供了遠端依賴,使用startup實現了無侵入初始化,使用方式如下:

1,新增依賴

allprojects {
repositories {
maven { url 'https://www.jitpack.io' }
}
}
dependencies {
implementation 'com.github.wdsqjq.AndLogin:lib:1.0.0'
kapt 'com.github.wdsqjq.AndLogin:apt_processor:1.0.0'
}

2,給需要登入的Activity添加註解

@RequireLogin
class TargetActivity1 : AppCompatActivity() {
...
}

@RequireLogin
class TargetActivity2 : AppCompatActivity() {
...
}

3,給登入Activity添加註解

@LoginActivity

class LoginActivity : AppCompatActivity() {
...
}

4,提供判斷是否登入的方法

需要是一個靜態方法。

@LoginActivity
class LoginActivity : AppCompatActivity() {

companion object {
// 該方法用於返回是否登入
@JudgeLogin
@JvmStatic
fun checkLogin(): Boolean {
return SpUtil.isLogin()
}
}
}

版權宣告:本文為博主原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處連結和本宣告。

本文連結:[

https://www.jianshu.com/p/7d8aed828f65

)

關注我獲取更多知識或者投稿