一文帶你全面掌握Android元件化核心!

語言: CN / TW / HK

 BATcoder技術 群,讓一部分人先進大廠

大家好,我是劉望舒,騰訊最具價值專家,著有三本業內知名暢銷書,連續五年蟬聯電子工業出版社年度優秀作者,百度百科收錄的資深技術專家。

前華為面試官、獨角獸公司技術總監。

想要 加入  BATcoder技術群,公號回覆 BAT  即可。

作者:看書的小蝸牛

https://www.jianshu.com/p/e7bbe365ebc1

前端開發經常遇到一個詞:路由,在Android APP開發中,路由還經常和元件化開發強關聯在一起,那麼到底什麼是路由,一個路由框架到底應該具備什麼功能,實現原理是什麼樣的?路由是否是APP的強需求呢?與元件化到底什麼關係,本文就簡單分析下如上幾個問題。

1.路由的概念

路由這個詞本身應該是網際網路協議中的一個詞,維基百科對此的解釋如下:

路由(routing)就是通過互聯的網路把資訊從源地址傳輸到目的地址的活動。路由發生在OSI網路參考模型中的第三層即網路層。

個人理解,在前端開發中,路由就是通過一串字串對映到對應業務的能力。APP的路由框首先能夠蒐集各元件的路由scheme,並生成路由表,然後,能夠根據外部輸入字串在路由表中匹配到對應的頁面或者服務,進行跳轉或者呼叫,並提供會獲取返回值等,示意如下:

所以一個基本路由框架要具備如下能力:

1. APP路由的掃描及註冊邏輯。

2.  路由跳轉target頁面能力。

3. 路由呼叫target服務能力。

APP中,在進行頁面路由的時候,經常需要判斷是否登入等一些額外鑑權邏輯所以,還需要提供攔截邏輯等,比如:登陸。

2.三方路由框架是否是APP強需求

答案:不是,系統原生提供路由能力,但功能較少,稍微大規模的APP都採用三方路由框架。

Android系統本身提供頁面跳轉能力:如startActivity,對於工具類APP,或單機類APP,這種方式已經完全夠用,完全不需要專門的路由框架,那為什麼很多APP還是採用路由框架呢?這跟APP性質及路由框架的優點都有關。比如淘寶、京東、美團等這些大型APP,無論是從APP功能還是從其研發團隊的規模上來說都很龐大,不同的業務之間也經常是不同的團隊在維護,採用元件化的開發方式,最終整合到一個APK中。


多團隊之間經常會涉及業務間的互動,比如從電影票業務跳轉到美食業務,但是兩個業務是兩個獨立的研發團隊,程式碼實現上是完全隔離的,那如何進行通訊呢?首先想到的是程式碼上引入,但是這樣會打破了低耦合的初衷,可能還會引入各種問題。

例如,部分業務是外包團隊來做,這就牽扯到程式碼安全問題,所以還是希望通過一種類似黑盒的方式,呼叫目標業務,這就需要中轉路由支援,所以國內很多APP都是用了路由框架的。其次我們各種跳轉的規則並不想跟具體的實現類扯上關係,比如跳轉商詳的時候,不希望知道是哪個Activity來實現,只需要一個字串對映過去即可,這對於H5、或者後端開發來處理跳轉的時候,就非常標準。

3.原生路由的限制:功能單一,擴充套件靈活性差,不易協同

傳統的路由基本上就限定在 startActivity 、或者 startService 來路由跳轉或者啟動服務。拿 startActivity 來說,傳統的路由有什麼缺點: startActivity 有兩種用法,一種是顯示的,一種是隱式的,顯示呼叫如下:

<!--1 匯入依賴-->
import com.snail.activityforresultexample.test.SecondActivity;

public class MainActivity extends AppCompatActivity {

    void jumpSecondActivityUseClassName(){
    <!--顯示的引用Activity類-->
        Intent intent =new Intent(MainActivity.this, SecondActivity.class);
        startActivity(intent);
    }

顯示呼叫的缺點很明顯,那就是必須要強依賴目標Activity的類實現,有些場景,尤其是大型APP元件化開發時候,有些業務邏輯出於安全考慮,並不想被原始碼或aar依賴,這時顯式依賴的方式就無法走通。再來看看隱式呼叫方法。

第一步:manifest中配置activity的 intent-filter ,至少要配置一個action。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.snail.activityforresultexample">
    <application
       ...
    <activity android:name=".test.SecondActivity">
            <intent-filter>
            <!--隱式呼叫必須配置android.intent.category.DEFAULT-->
                   <category android:name="android.intent.category.DEFAULT"/>
            <!--至少配置一個action才能通過隱式呼叫-->
                <action android:name="com.snail.activityforresultexample.SecondActivity" />
                <!--可選-->
  <!--              <data android:mimeType="video/mpeg" android:scheme="http" ... />-->
            </intent-filter>
        </activity>
    </application>
</manifest>

第二步:呼叫。

void jumpSecondActivityUseFilter() {
    Intent intent = new Intent();
    intent.setAction("com.snail.activityforresultexample.SecondActivity");
    startActivity(intent);
}

如果牽扯到資料傳遞寫法上會更復雜一些,隱式呼叫的缺點有如下幾點:

首先manifest中定義複雜,相對應的會導致暴露的協議變的複雜,不易維護擴充套件。

其次,不同Activity都要不同的action配置,每次增減修改Activity都會很麻煩,對比開發者非常不友好,增加了協作難度。

最後,Activity的export屬性並不建議都設定成True,這是降低風險的一種方式,一般都是收歸到一個Activity, DeeplinkActivitiy 統一處理跳轉,這種場景下, DeeplinkActivitiy 就兼具路由功能,隱式呼叫的場景下,新Activitiy的增減勢必每次都要調整路由表,這會導致開發效率降低,風險增加。

可以看到系統原生的路由框架,並沒太多考慮團隊協同的開發模式,多限定在一個模組內部多個業務間直接相互引用,基本都要程式碼級依賴,對於程式碼及業務隔離很不友好。如不考慮之前Dex方法樹超限制,可以認為三方路由框架完全是為了團隊協同而建立的。

4.APP三方路由框架需具備的能力

目前市面上大部分的路由框架都能搞定上述問題,簡單整理下現在三方路由的能力,可歸納如下:

路由表生成能力 :業務元件[UI業務及服務]自動掃描及註冊邏輯,需要擴充套件性好,無需入侵原有程式碼邏輯。

scheme與業務對映邏輯 :無需依賴具體實現,做到程式碼隔離。

基礎路由跳轉能力 :頁面跳轉能力的支援。

服務類元件的支援 :如去某個服務元件獲取一些配置等。

[擴充套件]路由攔截邏輯: 比如登陸,統一鑑權。

可定製的降級邏輯 :找不到元件時的兜底。

可以看下一個典型的Arouter用法,第一步:對新增頁面新增Router Scheme 宣告。

@Route(path = "/test/activity2")
public class Test2Activity extends AppCompatActivity {
     ...
}

build階段會根據註解蒐集路由scheme,生成路由表。第二步使用:

ARouter.getInstance()
        .build("/test/activity2")
        .navigation(this);

如上,在ARouter框架下,僅需要字串scheme,無需依賴任何Test2Activity就可實現路由跳轉。

5.APP路由框架的實現

路由框架實現的核心是建立scheme和元件[Activity或者其他服務]的對映關係,也就是路由表,並能根據路由表路由到對應元件的能力。其實分兩部分,第一部分路由表的生成,第二部分,路由表的查詢。

路由表的自動生成

生成路由表的方式有很多,最簡單的就是維護一個公共檔案或者類,裡面對映好每個實現元件跟scheme。

不過,這種做法缺點很明顯:每次增刪修改都要都要修改這個表,對於協同非常不友好,不符合解決協同問題的初衷。不過,最終的路由表倒是都是這條路,就是將所有的Scheme蒐集到一個物件中,只是實現方式的差別,目前幾乎所有的三方路由框架都是藉助註解+APT[Annotation Processing Tool]工具+AOP(Aspect-Oriented Programming,面向切面程式設計)來實現的,基本流程如下:

其中牽扯的技術有註解、APT(Annotation Processing Tool)、AOP(Aspect-Oriented Programming,面向切面程式設計)。APT常用的有JavaPoet,主要是遍歷所有類,找到被註解的Java類,然後聚合生成路由表,由於元件可能有很多,路由表可能也有也有多個,之後,這些生成的輔助類會跟原始碼一併被編譯成class檔案,之後利用AOP技術【如ASM或者JavaAssist】,掃描這些生成的class,聚合路由表,並填充到之前的佔位方法中,完成自動註冊的邏輯。

JavaPoet如何蒐集並生成路由表集合?

以ARouter框架為例,先定義Router框架需要的註解如:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.CLASS)
public @interface Route {

    /**
     * Path of route
     */
    String path();

該註解用於標註需要路由的元件,用法如下:

@Route(path = "/test/activity1", name = "測試用 Activity")
public class Test1Activity extends BaseActivity {
    @Autowired
    int age = 10;

之後利用APT掃描所有被註解的類,生成路由表,實現參考如下:

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    if (CollectionUtils.isNotEmpty(annotations)) {
    <!--獲取所有被Route.class註解標註的集合-->
        Set<? extends Element> routeElements = roundEnv.getElementsAnnotatedWith(Route.class);
        <!--解析並生成表-->
            this.parseRoutes(routeElements);
       ...
    return false;
}

 <!--生成中間路由表Java類-->
private void parseRoutes(Set<? extends Element> routeElements) throws IOException {
                        ...
                     // Generate groups
            String groupFileName = NAME_OF_GROUP + groupName;
            JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
                    TypeSpec.classBuilder(groupFileName)
                            .addJavadoc(WARNING_TIPS)
                            .addSuperinterface(ClassName.get(type_IRouteGroup))
                            .addModifiers(PUBLIC)
                            .addMethod(loadIntoMethodOfGroupBuilder.build())
                            .build()
            ).build().writeTo(mFiler);

產物如下:包含路由表,及區域性註冊入口。

自動註冊:ASM蒐集上述路由表並聚合插入Init程式碼區。

為了能夠插入到 Init 程式碼區,首先需要預留一個位置,一般定義一個空函式,以待後續填充:

public class RouterInitializer {

    public static void init(boolean debug, Class webActivityClass, IRouterInterceptor... interceptors) {
        ...
        loadRouterTables();
    }
    //自動註冊程式碼    
    public static void loadRouterTables() {

    }
}

首先利用AOP工具,遍歷上述APT中間產物,聚合路由表,並註冊到預留初始化位置,遍歷的過程牽扯是gradle transform的過程。

蒐集目標,聚合路由表

/**掃描jar*/
fun scanJar(jarFile: File, dest: File?) {

    val file = JarFile(jarFile)
    var enumeration = file.entries()
    while (enumeration.hasMoreElements()) {
        val jarEntry = enumeration.nextElement()
        if (jarEntry.name.endsWith("XXRouterTable.class")) {
            val inputStream = file.getInputStream(jarEntry)
            val classReader = ClassReader(inputStream)
            if (Arrays.toString(classReader.interfaces)
                    .contains("IHTRouterTBCollect")
            ) {
                tableList.add(
                    Pair(
                        classReader.className,
                        dest?.absolutePath
                    )
                )
            }
            inputStream.close()
        } else if (jarEntry.name.endsWith("HTRouterInitializer.class")) {
            registerInitClass = dest
        }
    }
    file.close()
}

對目標Class注入路由表初始化程式碼

fun asmInsertMethod(originFile: File?) {

    val optJar = File(originFile?.parent, originFile?.name + ".opt")
    if (optJar.exists())
        optJar.delete()
    val jarFile = JarFile(originFile)
    val enumeration = jarFile.entries()
    val jarOutputStream = JarOutputStream(FileOutputStream(optJar))

    while (enumeration.hasMoreElements()) {
        val jarEntry = enumeration.nextElement()
        val entryName = jarEntry.getName()
        val zipEntry = ZipEntry(entryName)
        val inputStream = jarFile.getInputStream(jarEntry)
        //插樁class
        if (entryName.endsWith("RouterInitializer.class")) {
            //class檔案處理
            jarOutputStream.putNextEntry(zipEntry)
            val classReader = ClassReader(IOUtils.toByteArray(inputStream))
            val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
            val cv = RegisterClassVisitor(Opcodes.ASM5, classWriter,tableList)
            classReader.accept(cv, EXPAND_FRAMES)
            val code = classWriter.toByteArray()
            jarOutputStream.write(code)
        } else {
            jarOutputStream.putNextEntry(zipEntry)
            jarOutputStream.write(IOUtils.toByteArray(inputStream))
        }
        jarOutputStream.closeEntry()
    }
    //結束
    jarOutputStream.close()
    jarFile.close()
    if (originFile?.exists() == true) {
        Files.delete(originFile.toPath())
    }
    optJar.renameTo(originFile)
}

最終 RouterInitializer.class loadRouterTables 會被修改成如下填充好的程式碼:

public static void loadRouterTables() {

  <!---->
  register("com.alibaba.android.arouter.routes.ARouter$$Root$$modulejava");
  register("com.alibaba.android.arouter.routes.ARouter$$Root$$modulekotlin");
  register("com.alibaba.android.arouter.routes.ARouter$$Root$$arouterapi");
  register("com.alibaba.android.arouter.routes.ARouter$$Interceptors$$modulejava");
  ...
}

如此就完成了路由表的蒐集與註冊,大概的流程就是如此。當然對於支援服務、Fragment等略有不同,但大體類似。

Router框架對服務類元件的支援

通過路由的方式獲取服務屬於APP路由比較獨特的能力,比如有個使用者中心的元件,我們可以通過路由的方式去查詢使用者是否處於登陸狀態,這種就不是狹義上的頁面路由的概念,通過一串字串如何查到對應的元件並呼叫其方法呢?這種的實現方式也有多種,每種實現方式都有自己的優劣。

一種是可以將服務抽象成介面,沉到底層,上層實現通過路由方式對映物件。

一種是將實現方法直接通過路由方式對映。

先看第一種,這種事Arouter的實現方式,它的優點是所有對外暴露的服務都暴露介面類【沉到底層】,這對於外部的呼叫方,也就是服務使用方非常友好,示例如下:

先定義抽象服務,並沉到底層

public interface HelloService extends IProvider {
    void sayHello(String name);
}

實現服務,並通過Router註解標記。

@Route(path = "/yourservicegroupname/hello")
public class HelloServiceImpl implements HelloService {
    Context mContext;

    @Override
    public void sayHello(String name) {
        Toast.makeText(mContext, "Hello " + name, Toast.LENGTH_SHORT).show();
    }

使用:利用Router加scheme獲取服務例項,並對映成抽象類,然後直接呼叫方法。

((HelloService) ARouter.getInstance().build("/yourservicegroupname/hello").navigation()).sayHello("mike");

這種實現方式對於使用方其實是很方便的,尤其是一個服務有多個可操作方法的時候,但是缺點是擴充套件性,如果想要擴充套件方法,就要改動底層庫。

再看第二種:將實現方法直接通過路由方式對映

服務的呼叫都要落到方法上,參考頁面路由,也可以支援方法路由,兩者並列關係,所以主要增加一個方法路由表,實現原理與Page路由類似,跟上面的Arouter對比,不用定義抽象層,直接定義實現即可:

定義Method的Router

public class HelloService {

    <!--引數 name-->
    @MethodRouter(url = {"arouter://sayhello"})
    public void sayHello(String name) {
        Toast.makeText(mContext, "Hello " + name, Toast.LENGTH_SHORT).show();
    }

使用即可

RouterCall.callMethod("arouter://sayhello?name=hello");

上述的缺點就是對於外部呼叫有些複雜,尤其是處理引數的時候,需要嚴格按照協議來處理,優點是,沒有抽象層,如果需要擴充套件服務方法,不需要改動底層。

上述兩種方式各有優劣,不過,如果從做服務元件的初衷出發,第一種比較好:對於呼叫方比較友好。另外對於CallBack的支援,Arouter的處理方式可能也會更方便一些,可以比較方便的交給服務方定義。如果是第二種,服務直接通過路由對映的方式,處理起來就比較麻煩,尤其是Callback中的引數,可能要統一封裝成JSON並維護解析的協議,這樣處理起來,可能不是很好。

路由表的匹配

路由表的匹配比較簡單,就是在全域性Map中根據String輸入,匹配到目標元件,然後依賴反射等常用操作,定位到目標。

6.元件化與路由的關係

元件化是一種開發整合模式,更像一種開發規範,更多是為團隊協同開發帶來方便。元件化最終落地是一個個獨立的業務及功能元件,這些元件之間可能是不同的團隊,處於不同的目的在各自維護,甚至是需要程式碼隔離,如果牽扯到元件間的呼叫與通訊,就不可避免的藉助路由,因為實現隔離的,只能採用通用字串scheme進行通訊,這就是路由的功能範疇。

元件化需要路由支撐的根本原因: 元件間程式碼實現的隔離

總結

路由不是一個APP的必備功能,但是大型跨團隊的APP基本都需要。

路由框架的基本能力:路由自動註冊、路由表蒐集、服務及UI介面路由及攔截等核心功能。

元件化與路由的關係:元件化的程式碼隔離導致路由框架成為必須。