Android 組件化最佳實踐

演示為先


在項目的開發(fā)過程中姆吭,隨著開發(fā)人員的增多及功能的增加榛做,如果提前沒有使用合理的開發(fā)架構(gòu)唁盏,那么代碼會越來臃腫内狸,功能間代碼耦合也會越來越嚴(yán)重,這時候為了保證項目代碼的質(zhì)量厘擂,我們就必須進(jìn)行重構(gòu)昆淡。

比較簡單的開發(fā)架構(gòu)是按照功能模塊進(jìn)行拆分,也就是用 Android 開發(fā)中的 module 這個概念刽严,每個功能都是一個 module昂灵,每個功能的代碼都在自己所屬的 module 中添加避凝。這樣的設(shè)計在各個功能相互直接比較獨立的情況下是比較合理的,但是當(dāng)多個模塊中涉及到相同功能時代碼的耦合又會增加眨补。

例如首頁模塊和直播間模塊中都可能涉及到了視頻播放的功能管削,這時候不管將播放控制的代碼放到首頁還是直播間,開發(fā)過程中都會發(fā)現(xiàn)撑螺,我們想要解決的代碼耦合情況又又又又出現(xiàn)了含思。為了進(jìn)一步解決這個問題,組件化的開發(fā)模式順勢而來甘晤。

一含潘、組件化和模塊化的區(qū)別

上面說到了從普通的無架構(gòu)到模塊化,再由模塊化到組件化线婚,那么其中的界限是什么遏弱,模塊化和組件化的本質(zhì)區(qū)別又是什么?為了解決這些問題塞弊,我們就要先了解 “模塊” 和 “組件” 的區(qū)別漱逸。

- 模塊

模塊指的是獨立的業(yè)務(wù)模塊,比如剛才提到的 [首頁模塊]居砖、[直播間模塊] 等虹脯。

- 組件

組件指的是單一的功能組件,如 [視頻組件]奏候、[支付組件] 等循集,每個組件都可以以一個單獨的 module 開發(fā),并且可以單獨抽出來作為 SDK 對外發(fā)布使用蔗草。

由此來看咒彤,[模塊] 和 [組件] 間最明顯的區(qū)別就是模塊相對與組件來說粒度更大,一個模塊中可能包含多個組件咒精。并且兩種方式的本質(zhì)思想是一樣的镶柱,都是為了代碼重用和業(yè)務(wù)解耦。在劃分的時候模叙,模塊化是業(yè)務(wù)導(dǎo)向歇拆,組件化是功能導(dǎo)向。



上面是一個非撤蹲桑基礎(chǔ)的組件化架構(gòu)圖故觅,圖中從上向下分別為應(yīng)用層、組件層和基礎(chǔ)層渠啊。

基礎(chǔ)層: 基礎(chǔ)層很容易理解输吏,其中包含的是一些基礎(chǔ)庫以及對基礎(chǔ)庫的封裝,比如常用的圖片加載替蛉,網(wǎng)絡(luò)請求贯溅,數(shù)據(jù)存儲操作等等拄氯,其他模塊或者組件都可以引用同一套基礎(chǔ)庫,這樣不但只需要開發(fā)一套代碼它浅,還解耦了基礎(chǔ)功能和業(yè)務(wù)功能的耦合译柏,在基礎(chǔ)庫變更時更加容易操作。

組件層: 基礎(chǔ)層往上是組件層姐霍,組件層就包含一些簡單的功能組件艇纺,比如視頻,支付等等

應(yīng)用層: 組件層往上是應(yīng)用層邮弹,這里為了簡單黔衡,只添加了一個 APP ,APP 就相當(dāng)于我們的模塊腌乡,一個具體的業(yè)務(wù)模塊會按需引用不同的組件盟劫,最終實現(xiàn)業(yè)務(wù)功能,這里如果又多個業(yè)務(wù)模塊与纽,就可以各自按需引用組件侣签,最后將各個模塊統(tǒng)籌輸出 APP。

到這里我們最簡單的組件化架構(gòu)就已經(jīng)可以使用了急迂,但是這只是最理想的狀態(tài)下的架構(gòu)影所,實際的開發(fā)中,不同的組件不可能徹底的相互隔離僚碎,組件中肯定會有相互傳遞數(shù)據(jù)猴娩、調(diào)用方法、頁面跳轉(zhuǎn)等情況勺阐。

比如直播組件中用戶需要刷禮物卷中,刷禮物就需要支付組件的支持,而支付組件中支付操作是必須需要登錄狀態(tài)渊抽、用戶 ID 等信息蟆豫。如果當(dāng)前未登錄,是需要先跳轉(zhuǎn)到登錄組件中進(jìn)行登錄操作懒闷,登錄成功后才能正常的進(jìn)行支付流程十减。

而我們上面的架構(gòu)圖中,各個組件之間是相互隔離的愤估,沒有相互依賴帮辟,如果想直接進(jìn)行組件交互,也就是組件間相互依賴灵疮,這就又違背了組件化開發(fā)的規(guī)則织阅。所以我們必須找到方法解決這些問題才能進(jìn)行組件化開發(fā)壳繁。

二震捣、組件化開發(fā)需要解決的問題

在實現(xiàn)組件化的過程中荔棉,同一個問題可能有不同的技術(shù)路徑可以解決,但是需要解決的問題主要有以下幾點:

1.每個組件都是一個完整的整體蒿赢,所以組件開發(fā)過程中要滿足單獨運行及調(diào)試的要求润樱,這樣還可以提升開發(fā)過程中項目的編譯速度。

2.數(shù)據(jù)傳遞與組件間方法的相互調(diào)用羡棵,這也是上面我們提到的一個必須要解決的問題壹若。

3.組件間界面跳轉(zhuǎn),不同組件之間不僅會有數(shù)據(jù)的傳遞皂冰,也會有相互的頁面跳轉(zhuǎn)。在組件化開發(fā)過程中如何在不相互依賴的情況下實現(xiàn)互相跳轉(zhuǎn)赂蕴?

4.主項目不直接訪問組件中具體類的情況下概说,如何獲取組件中 Fragment 的實例并將組件中的 Fragment 實例添加到主項目的界面中糖赔?

5.組件開發(fā)完成后相互之間的集成調(diào)試如何實現(xiàn)放典?還有就是在集成調(diào)試階段,依賴多個組件進(jìn)行開發(fā)時声怔,如果實現(xiàn)只依賴部分組件時可以編譯通過醋火?這樣也會降低編譯時間,提升效率茬高。

6.組件解耦的目標(biāo)以及如何實現(xiàn)代碼隔離丽猬?不僅組件之間相互隔離,還有第五個問題中模塊依賴組件時可以動態(tài)增刪組件谬以,這樣就是模塊不會對組件中特定的類進(jìn)行操作,所以完全的隔絕模塊對組件中類的使用會使解耦更加徹底铭乾,程序也更加健壯片橡。

以上就是實現(xiàn)組件化的過程中我們要解決的主要問題捧书,下面我們會一個一個來解決,最終實現(xiàn)比較合理的組件化開發(fā)舆吮。

三色冀、組件單獨調(diào)試

1.動態(tài)配置組件的工程類型?

在 AndroidStudio 開發(fā) Android 項目時与学,使用的是 Gradle 來構(gòu)建索守,具體來說使用的是 Android Gradle 插件來構(gòu)建卵佛,Android Gradle 中提供了三種插件疾牲,在開發(fā)中可以通過配置不同的插件來配置不同的工程鸥跟。

  • App 插件枫匾,id: com.android.application
  • Library 插件干茉,id: com.android.libraay
  • Test 插件,id: com.android.test

區(qū)別比較簡單戳鹅, App 插件來配置一個 Android App 工程枫虏,項目構(gòu)建后輸出一個 APK 安裝包隶债,Library 插件來配置一個 Android Library 工程,構(gòu)建后輸出 aar 包回俐,Test 插件來配置一個 Android Test 工程仅颇。我們這里主要使用 App 插件和 Library 插件來實現(xiàn)組件的單獨調(diào)試。這里就出現(xiàn)了第一個小問題耕皮,如何動態(tài)配置組件的工程類型粱年?

通過工程的 build.gradle 文件中依賴的 Android Gradle 插件 id 來配置工程的類型台诗,但是我們的組件既可以單獨調(diào)試又可以被其他模塊依賴,所以這里的插件 id 我們不應(yīng)該寫死粱快,而是通過在 module 中添加一個 gradle.properties 配置文件事哭,在配置文件中添加一個布爾類型的變量 isRunAlone,在 build.gradle 中通過 isRunAlone 的值來使用不同的插件從而配置不同的工程類型流炕,在單獨調(diào)試和集成調(diào)試時直接修改 isRunAlone 的值即可每辟。例如,在 Share 分享組件中的配置:



2. 如何動態(tài)配置組件的 ApplicationId 和 AndroidManifest 文件

除了通過依賴的插件來配置不同的工程挠将,我們還要根據(jù) isRunAlone 的值來修改其他配置,一個 APP 是只有一個 ApplicationId 的内贮,所以在單獨調(diào)試和集成調(diào)試時組件的 ApplicationId 應(yīng)該是不同的什燕;一般來說一個 APP 也應(yīng)該只有一個啟動頁屎即, 在組件單獨調(diào)試時也是需要有一個啟動頁,在集成調(diào)試時如果不處理啟動頁的問題虽另,主工程和組件的 AndroidManifes 文件合并后就會出現(xiàn)兩個啟動頁,這個問題也是需要解決的募寨。

ApplicationId 和 AndroidManifest 文件都是可以在 build.gradle 文件中進(jìn)行配置的拔鹰,所以我們同樣通過動態(tài)配置組件工程類型時定義的 isRunAlone 這個變量的值來動態(tài)修改 ApplicationId 和 AndroidManifest。首先我們要新建一個 AndroidManifest.xml 文件瓷马,加上原有的 AndroidManifest 文件欧聘,在兩個文件中就可以分別配置單獨調(diào)試和集成調(diào)試時的不同的配置,如圖:



其中 AndroidManifest 文件中的內(nèi)容如下:

// main/manifest/AndroidManifest.xml 單獨調(diào)試
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.loong.share">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".ShareActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

// main/AndroidManifest.xml 集成調(diào)試
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.loong.share">

    <application android:theme="@style/AppTheme">
        <activity android:name=".ShareActivity"/>
    </application>

</manifest>

然后在 build.gradle 中通過判斷 isRunAlone 的值,來配置不同的 ApplicationId 和 AndroidManifest.xml 文件的路徑:

// share 組件的 build.gradle

android {
    defaultConfig {
        if (isRunAlone.toBoolean()) {
            // 單獨調(diào)試時添加 applicationId 凉敲,集成調(diào)試時移除
            applicationId "com.loong.login"
        }
        ...
    }
    
    sourceSets {
        main {
            // 單獨調(diào)試與集成調(diào)試時使用不同的 AndroidManifest.xml 文件
            if (isRunAlone.toBoolean()) {
                manifest.srcFile 'src/main/manifest/AndroidManifest.xml'
            } else {
                manifest.srcFile 'src/main/AndroidManifest.xml'
            }
        }
    }
}


到這里我們就解決了組件化開發(fā)時遇到的第一個問題势决,實現(xiàn)了組件的單獨調(diào)試與集成調(diào)試果复,并在不同情況時使用的不同配置。當(dāng)然 build.gradle 中通過 Android Gradle 插件迈窟,我們還可以根據(jù)不同工程配置不同的 Java 源代碼车酣、不同的 resource 資源文件等的,有了上面問題的解決方式娘摔,這些問題就都可以解決了凳寺。

四肠缨、組件間數(shù)據(jù)傳遞與方法的相互調(diào)用

由于主項目與組件,組件與組件之間都是不可以直接使用類的相互引用來進(jìn)行數(shù)據(jù)傳遞的吴汪,那么在開發(fā)過程中如果有組件間的數(shù)據(jù)傳遞時應(yīng)該如何解決呢漾橙,這里我們可以采用 [接口 + 實現(xiàn)] 的方式來解決脾歇。

在這里可以添加一個 ComponentBase 模塊藕各,這個模塊被所有的組件依賴,在這個模塊中分別添加定義了組件可以對外提供訪問自身數(shù)據(jù)的抽象方法的 Service乌逐。ComponentBase 中還提供了一個 ServiceFactory浙踢,每個組件中都要提供一個類實現(xiàn)自己對應(yīng)的 Service 中的抽象方法洛波。在組件加載后思瘟,需要創(chuàng)建一個實現(xiàn)類的對象滨攻,然后將實現(xiàn)了 Service 的類的對象添加到 ServiceFactory 中光绕。這樣在不同組件交互時就可以通過 ServiceFactory 獲取想要調(diào)用的組件的接口實現(xiàn)欣尼,然后調(diào)用其中的特定方法就可以實現(xiàn)組件間的數(shù)據(jù)傳遞與方法調(diào)用愕鼓。

當(dāng)然菇晃,ServiceFactory 中也會提供所有的 Service 的空實現(xiàn)磺送,在組件單獨調(diào)試或部分集成調(diào)試時避免出現(xiàn)由于實現(xiàn)類對象為空引起的空指針異常崇呵。

下面我們就按照這個方法來解決組件間數(shù)據(jù)傳遞與方法的相互調(diào)用這個問題演熟,這里我們通過分享組件 中調(diào)用 登錄組件 中的方法來獲取登錄狀態(tài)這個場景來演示芒粹。

1.創(chuàng)建 componentbase 模塊

AndroidStudio 中創(chuàng)建模塊比較簡單化漆,通過菜單欄中的 File -> New -> New Module 來創(chuàng)建我們的 componentbase 模塊座云。需要注意的是我們在創(chuàng)建組件時需要使用 Phone & Tablet Module ,創(chuàng)建 componentbase 模塊時使用 Android Library 來創(chuàng)建厌衔,其中的區(qū)別是通過 Phone & Tablet Module 創(chuàng)建的默認(rèn)是 APP 工程睬隶,通過 Android Library 創(chuàng)建的默認(rèn)是 Library 工程苏潜,區(qū)別我們上面已經(jīng)說過了恤左。當(dāng)然如果選錯了也不要緊,在 buidl.gradle 中也可以自己來修改配置授嘀。如下圖:



這里 Login 組件中提供獲取登錄狀態(tài)和獲取登錄用戶 AccountId 的兩個方法览闰,分享組件中的分享操作需要用戶登錄才可以進(jìn)行压鉴,如果用戶未登錄則不進(jìn)行分享操作油吭。我們先看一下 componentbase 模塊中的文件結(jié)構(gòu):



其中 service 文件夾中定義接口, IAccountService 接口中定義了 Login 組件向外提供的數(shù)據(jù)傳遞的接口方法心包,empty_service 中是 service 中定義的接口的空實現(xiàn)蟹腾,ServiceFactory 接收組件中實現(xiàn)的接口對象的注冊以及向外提供特定組件的接口實現(xiàn)。
// IAccountService
public interface IAccountService {

    /**
     * 是否已經(jīng)登錄
     * @return
     */
    boolean isLogin();

    /**
     * 獲取登錄用戶的 AccountId
     * @return
     */
    String getAccountId();
}

// EmptyAccountService
public class EmptyAccountService implements IAccountService {
    @Override
    public boolean isLogin() {
        return false;
    }

    @Override
    public String getAccountId() {
        return null;
    }
}

// ServiceFacoty
public class ServiceFactory {

    private IAccountService accountService;

    /**
     * 禁止外部創(chuàng)建 ServiceFactory 對象
     */
    private ServiceFactory() {
    }

    /**
     * 通過靜態(tài)內(nèi)部類方式實現(xiàn) ServiceFactory 的單例
     */
    public static ServiceFactory getInstance() {
        return Inner.serviceFactory;
    }

    private static class Inner {
        private static ServiceFactory serviceFactory = new ServiceFactory();
    }

    /**
     * 接收 Login 組件實現(xiàn)的 Service 實例
     */
    public void setAccountService(IAccountService accountService) {
        this.accountService = accountService;
    }

    /**
     * 返回 Login 組件的 Service 實例
     */
    public IAccountService getAccountService() {
        if (accountService == null) {
            accountService = new EmptyAccountService();
        }
        return accountService;
    }
}

前面我們提到的組件化架構(gòu)圖中,所有的組件都依賴 Base 模塊柿隙,而 componentbase 模塊也是所有組件需要依賴的禀崖,所以我們可以讓 Base 模塊依賴 componentbase 模塊波附,這樣在組件中依賴 Base 模塊后就可以訪問 componentbase 模塊中的類掸屡。

2. Login 組件在 ServiceFactory 中注冊接口對象

在 componentbase 定義好 Login 組件需要提供的 Service 后,Login 組件需要依賴 componentbase 模塊盏求,然后在 Login 組件中創(chuàng)建類實現(xiàn) IAccountService 接口并實現(xiàn)其中的接口方法磅废,并在 Login 組件初始化(最好是在 Application 中) 時將 IAccountService 接口的實現(xiàn)類對象注冊到 ServiceFactory 中。相關(guān)代碼如下:

// Base 模塊的 build.gradle
dependencies {
    api project (':componentbase')
    ...
}

// login 組件的 build.gradle
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation project (':base')
}

// login 組件中的 IAccountService 實現(xiàn)類
public class AccountService implements IAccountService {
    @Override
    public boolean isLogin() {
        return AccountUtils.userInfo != null;
    }

    @Override
    public String getAccountId() {
        return AccountUtils.userInfo == null ? null : AccountUtils.userInfo.getAccountId();
    }
}

// login 組件中的 Aplication 類
public class LoginApp extends BaseApp {

    @Override
    public void onCreate() {
        super.onCreate();
        // 將 AccountService 類的實例注冊到 ServiceFactory
        ServiceFactory.getInstance().setAccountService(new AccountService());
    }
}

以上代碼就是 Login 組件中對外提供服務(wù)的關(guān)鍵代碼宫峦,到這里有的小伙伴可能想到了斗遏,一個項目時只能有一個 Application 的,Login 作為組件時逾一,主模塊的 Application 類會初始化遵堵,而 Login 組件中的 Applicaiton 不會初始化。確實是存在這個問題的壳坪,我們這里先將 Service 的注冊放到這里,稍后我們會解決 Login 作為組件時 Appliaciton 不會初始化的問題蝎亚。

3. Share 組件與 Login 組件實現(xiàn)數(shù)據(jù)傳遞

Login 組件中將 IAccountService 的實現(xiàn)類對象注冊到 ServiceFactory 中以后发框,其他模塊就可以使用這個 Service 與 Login 組件進(jìn)行數(shù)據(jù)傳遞顾患,我們在 Share 組件中需要使用登錄狀態(tài)江解,接下來我們看 Share 組件中如何使用 Login 組件提供的 Service。

同樣桨螺,Share 組件也是依賴了 Base 模塊的,所以也可以直接訪問到 componentbase 模塊中的類肝箱,在 Share 組件中直接通過 ServiceFactory 對象的 getAccountService 即可獲取到 Login 組件提供的 IAccountService 接口的實現(xiàn)類對象,然后通過調(diào)用該對象的方法即可實現(xiàn)與 Login 組件的數(shù)據(jù)傳遞稀蟋。主要代碼如下:

// Share 組件的 buidl.gradle
dependencies {
    implementation project (':base')
    ...
}

// Share 組件的 ShareActivity
public class ShareActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_share);

        share();
    }

    private void share() {
        if(ServiceFactory.getInstance().getAccountService().isLogin()) {
            Toast.makeText(this, "分享成功", Toast.LENGTH_SHORT);
        } else {
            Toast.makeText(this, "分享失敾驼拧:用戶未登錄", Toast.LENGTH_SHORT);
        }
    }
}

這樣的開發(fā)模式實現(xiàn)了各個組件間的數(shù)據(jù)傳遞都是基于接口編程,接口和實現(xiàn)完全分離退客,所以就實現(xiàn)了組件間的解耦骏融。在組件內(nèi)部的實現(xiàn)類對方法的實現(xiàn)進(jìn)行修改時,更極端的情況下萌狂,我們直接刪除、替換了組件時,只要新加的組件實現(xiàn)了對應(yīng) Service 中的抽象方法并在初始化時將實現(xiàn)類對象注冊到 ServiceFactory 中,其他與這個組件有數(shù)據(jù)傳遞的組件都不需要有任何修改天通。

到這里我們組件間數(shù)據(jù)傳遞和方法調(diào)用的問題就已經(jīng)解決了诺祸,其實胃夏,組件間交互還有很多其他的方式饺蚊,比如 EventBus曙求,廣播挤渐,數(shù)據(jù)持久化等方式植兰,但是往往這些方式的交互會不那么直觀,所以對通過 Service 這種形式可以實現(xiàn)的交互孤个,我們最好通過這種方式進(jìn)行。

4. 組件 Application 的動態(tài)配置

上面提到了由于 Application 的替換原則,在主模塊中有 Application 等情況下拧抖,組件在集中調(diào)試時其 Applicaiton 不會初始化的問題。而我們組件的 Service 在 ServiceFactory 的注冊又必須放到組件初始化的地方。

為了解決這個問題可以將組件的 Service 類強引用到主 Module 的 Application 中進(jìn)行初始化谋作,這就必須要求主模塊可以直接訪問組件中的類谬晕。而我們又不想在開發(fā)過程中主模塊能訪問組件中的類,這里可以通過反射來實現(xiàn)組件 Application 的初始化澳泵。

1)第一步:在 Base 模塊中定義抽象類 BaseApp 繼承 Application碰辅,里面定義了兩個方法,initModeApp 是初始化當(dāng)前組件時需要調(diào)用的方法顽素,initModuleData 是所有組件的都初始化后再調(diào)用的方法段审。

// Base 模塊中定義
public abstract class BaseApp extends Application {
    /**
     * Application 初始化
     */
    public abstract void initModuleApp(Application application);

    /**
     * 所有 Application 初始化后的自定義操作
     */
    public abstract void initModuleData(Application application);
}

2)第二步:所有的組件的 Application 都繼承 BaseApp催式,并在對應(yīng)的方法中實現(xiàn)操作堂氯,我們這里還是以 Login 組件為例晶框,其 LoginApp 實現(xiàn)了 BaseApp 接口卡睦,其 initModuleApp 方法中完成了在 ServiceFactory 中注冊自己的 Service 對象士骤。在單獨調(diào)試時 onCreate() 方法中也會調(diào)用 initModuleApp() 方法完成在 ServiceFactory 中的注冊操作。

// Login 組件的 LoginApp
public class LoginApp extends BaseApp {

    @Override
    public void onCreate() {
        super.onCreate();
        initModuleApp(this);
        initModuleData(this);
    }

    @Override
    public void initModuleApp(Application application) {
        ServiceFactory.getInstance().setAccountService(new AccountService());
    }

    @Override
    public void initModuleData(Application application) {

    }
}

3)第三步:在 Base 模塊中定義 AppConfig 類煤辨,其中的 moduleApps 是一個靜態(tài)的 String 數(shù)組,我們將需要初始化的組件的 Application 的完整類名放入到這個數(shù)組中锹引。

// Base 模塊的 AppConfig
public class AppConfig {
    private static final String LoginApp = "com.loong.login.LoginApp";

    public static String[] moduleApps = {
            LoginApp
    };
}

4)第四步:主 module 的 Application 也繼承 BaseApp ,并實現(xiàn)兩個初始化方法,在這兩個初始化方法中遍歷 AppcConfig 類中定義的 moduleApps 數(shù)組中的類名人断,通過反射斥滤,初始化各個組件的 Application。

// 主 Module 的 Applicaiton
public class MainApplication extends BaseApp {
    @Override
    public void onCreate() {
        super.onCreate();
        
        // 初始化組件 Application
        initModuleApp(this);
        
        // 其他操作
        
        // 所有 Application 初始化后的操作
        initModuleData(this);
        
    }

    @Override
    public void initModuleApp(Application application) {
        for (String moduleApp : AppConfig.moduleApps) {
            try {
                Class clazz = Class.forName(moduleApp);
                BaseApp baseApp = (BaseApp) clazz.newInstance();
                baseApp.initModuleApp(this);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            }
        }
    }

    @Override
    public void initModuleData(Application application) {
        for (String moduleApp : AppConfig.moduleApps) {
            try {
                Class clazz = Class.forName(moduleApp);
                BaseApp baseApp = (BaseApp) clazz.newInstance();
                baseApp.initModuleData(this);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            }
        }
    }
}

5. 組件間界面跳轉(zhuǎn)

Android 中的界面跳轉(zhuǎn)评雌,主要有顯式 Intent 和隱式 Intent 兩種派阱。在同一個組件中寨辩,因為類可以自由訪問秸滴,所以界面跳轉(zhuǎn)可以通過顯式 Intent 的方式實現(xiàn)。而在組件化開發(fā)中,由于不同組件式?jīng)]有相互依賴的,所以不可以直接訪問彼此的類项戴,這時候就沒辦法通過顯式的方式實現(xiàn)了棕叫。

Android 中提供的隱式 Intent 的方式可以實現(xiàn)這個需求坏怪,但是隱式 Intent 需要通過 AndroidManifest 集中管理贝润,協(xié)作開發(fā)比較麻煩。所以在這里我們采取更加靈活的一種方式铝宵,使用 Alibaba 開源的 ARouter 來實現(xiàn)。

一個用于幫助 Android App 進(jìn)行組件化改造的框架 —— 支持模塊間的路由华畏、通信鹏秋、解耦

由 github 上 ARouter 的介紹可以知道,它可以實現(xiàn)組件間的路由功能亡笑。路由是指從一個接口上收到數(shù)據(jù)包侣夷,根據(jù)數(shù)據(jù)路由包的目的地址進(jìn)行定向并轉(zhuǎn)發(fā)到另一個接口的過程。這里可以體現(xiàn)出路由跳轉(zhuǎn)的特點仑乌,非常適合組件化解耦百拓。

要使用 ARouter 進(jìn)行界面跳轉(zhuǎn),需要我們的組件對 Arouter 添加依賴晰甚,因為所有的組件都依賴了 Base 模塊衙传,所以我們在 Base 模塊中添加 ARouter 的依賴即可。其它組件共同依賴的庫也最好都放到 Base 中統(tǒng)一依賴厕九。

這里需要注意的是蓖捶,arouter-compiler 的依賴需要所有使用到 ARouter 的模塊和組件中都單獨添加,不然無法在 apt 中生成索引文件扁远,也就無法跳轉(zhuǎn)成功俊鱼。并且在每一個使用到 ARouter 的模塊和組件的 build.gradle 文件中刻像,其 android{} 中的 javaCompileOptions 中也需要添加特定配置。

// Base 模塊的 build.gradle
dependencies {
    api 'com.alibaba:arouter-api:1.3.1'
    // arouter-compiler 的注解依賴需要所有使用 ARouter 的 module 都添加依賴
    annotationProcessor 'com.alibaba:arouter-compiler:1.1.4'
}

// 所有使用到 ARouter 的組件和模塊的 build.gradle
android {
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = [ moduleName : project.getName() ]
            }
        }
    }
}

dependencies {
    ...
    implementation project (':base')
    annotationProcessor 'com.alibaba:arouter-compiler:1.1.4'
}

// 主項目的 build.gradle 需要添加對 login 組件和 share 組件的依賴
dependencies {
    // ... 其他
    implementation project(':login')
    implementation project(':share')
}

添加了對 ARouter 的依賴后并闲,還需要在項目的 Application 中將 ARouter 初始化细睡,我們這里將 ARouter 的初始化工作放到主項目 Application 的 onCreate 方法中,在應(yīng)用啟動的同時將 ARouter 初始化帝火。

// 主項目的 Application
public class MainApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();

        // 初始化 ARouter
        if (isDebug()) {           
            // 這兩行必須寫在init之前溜徙,否則這些配置在init過程中將無效
            
            // 打印日志
            ARouter.openLog();     
            // 開啟調(diào)試模式(如果在InstantRun模式下運行,必須開啟調(diào)試模式购公!線上版本需要關(guān)閉,否則有安全風(fēng)險)
            ARouter.openDebug();   
        }
        
        // 初始化 ARouter
        ARouter.init(this);
        
        // 其他操作 ...
    }

    private boolean isDebug() {
        return BuildConfig.DEBUG;
    }
    
    // 其他代碼 ...
}

這里我們以主項目跳登錄界面萌京,然后登錄界面登錄成功后跳分享組件的分享界面為例。其中分享功能還使用了我們上面提到的調(diào)用登錄組件的 Service 對登錄狀態(tài)進(jìn)行判斷宏浩。

首先知残,需要在登錄和分享組件中分別添加 LoginActivity 和 ShareActivity ,然后分別為兩個 Activity 添加注解 Route比庄,其中 path 是跳轉(zhuǎn)的路徑求妹,這里的路徑需要注意的是至少需要有兩級,/xx/xx

Login 組件的 LoginActivity:

@Route(path = "/account/login")
public class LoginActivity extends AppCompatActivity {

    private TextView tvState;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        initView();
        updateLoginState();
    }

    private void initView() {
        tvState = (TextView) findViewById(R.id.tv_login_state);
    }

    public void login(View view) {
        AccountUtils.userInfo = new UserInfo("10086", "Admin");
        updateLoginState();
    }

    private void updateLoginState() {
        tvState.setText("這里是登錄界面:" + (AccountUtils.userInfo == null ? "未登錄" : AccountUtils.userInfo.getUserName()));
    }

    public void exit(View view) {
        AccountUtils.userInfo = null;
        updateLoginState();
    }

    public void loginShare(View view) {
        ARouter.getInstance().build("/share/share").withString("share_content", "分享數(shù)據(jù)到微博").navigation();
    }
}


Share 組件的 ShareActivity:

@Route(path = "/share/share")
public class ShareActivity extends AppCompatActivity {
    private TextView tvState;
    private Button btnLogin, btnExit;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        initView();
        updateLoginState();
    }

    private void initView() {
        tvState = (TextView) findViewById(R.id.tv_login_state);
    }

    public void login(View view) {
        AccountUtils.userInfo = new UserInfo("10086", "Admin");
        updateLoginState();
    }

    public void exit(View view) {
        AccountUtils.userInfo = null;
        updateLoginState();
    }

    public void loginShare(View view) {
        ARouter.getInstance().build("/share/share").withString("share_content", "分享數(shù)據(jù)到微博").navigation();
    }
    
    private void updateLoginState() {
        tvState.setText("這里是登錄界面:" + (AccountUtils.userInfo == null ? "未登錄" : AccountUtils.userInfo.getUserName()));
    }
}

然后在 MainActivity 中通過 ARouter 跳轉(zhuǎn)佳窑,其中build 處填的是 path 地址制恍,withXXX 處填的是 Activity 跳轉(zhuǎn)時攜帶的參數(shù)的 key 和 value,navigation 就是發(fā)射了路由跳轉(zhuǎn)神凑。

// 主項目的 MainActivity
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    /**
     * 跳登錄界面
     * @param view
     */
    public void login(View view){
        ARouter.getInstance().build("/account/login").navigation();
    }

    /**
     * 跳分享界面
     * @param view
     */
    public void share(View view){
        ARouter.getInstance().build("/share/share").withString("share_content", "分享數(shù)據(jù)到微博").navigation();
    }
}

如果研究過 ARouter 源碼的同學(xué)可能知道净神,ARouter擁有自身的編譯時注解框架,其跳轉(zhuǎn)功能是通過編譯時生成的輔助類完成的溉委,最終的實現(xiàn)實際上還是調(diào)用了 startActivity鹃唯。

路由的另外一個重要作用就是過濾攔截,以 ARouter 為例瓣喊,如果我們定義了過濾器坡慌,在模塊跳轉(zhuǎn)前會遍歷所有的過濾器,然后通過判斷跳轉(zhuǎn)路徑來找到需要攔截的跳轉(zhuǎn)藻三,比如上面我們提到的分享功能一般都是需要用戶登錄的洪橘,如果我們不想在所有分享的地方都添加登錄狀態(tài)的判斷,我們就可以使用路由的過濾功能棵帽,我們就以這個功能來演示熄求,我們可以定義一個簡單的過濾器:

// Login 模塊中的登錄狀態(tài)過濾攔截器
@Interceptor(priority = 8, name = "登錄狀態(tài)攔截器")
public class LoginInterceptor implements IInterceptor {

    private Context context;

    @Override
    public void process(Postcard postcard, InterceptorCallback callback) {

        // onContinue 和 onInterrupt 至少需要調(diào)用其中一種,否則不會繼續(xù)路由
        
        if (postcard.getPath().equals("/share/share")) {
            if (ServiceFactory.getInstance().getAccountService().isLogin()) {
                callback.onContinue(postcard);  // 處理完成岖寞,交還控制權(quán)
            } else {
                callback.onInterrupt(new RuntimeException("請登錄")); // 中斷路由流程
            }
        } else {
            callback.onContinue(postcard);  // 處理完成抡四,交還控制權(quán)
        }

    }

    @Override
    public void init(Context context) {
        // 攔截器的初始化,會在sdk初始化的時候調(diào)用該方法,僅會調(diào)用一次
        this.context = context;
    }
}

自定義的過濾器需要通過 @Tnterceptor 來注解指巡,priority 是優(yōu)先級淑履,name 是對這個攔截器的描述。以上代碼中通過 Postcard 獲取跳轉(zhuǎn)的 path藻雪,然后通過 path 以及特定的需求來判斷是否攔截秘噪,在這里是通過對登錄狀態(tài)的判斷進(jìn)行攔截,如果已經(jīng)登錄就攔截跳轉(zhuǎn)勉耀。

五指煎、主項目如何在不直接訪問組件中具體類的情況下使用組件的 Fragment

除了 Activity 的跳轉(zhuǎn),我們在開發(fā)過程中也會經(jīng)常使用 Fragment便斥,一種很常見的樣式就是應(yīng)用主頁 Activity 中包含了多個隸屬不同組件的 Fragment至壤。一般情況下,我們都是直接通過訪問具體 Fragment 類的方式實現(xiàn) Fragment 的實例化枢纠,但是現(xiàn)在為了實現(xiàn)模塊與組件間的解耦像街,在移除組件時不會由于引用的 Fragment 不存在而編譯失敗,我們就不能模塊中直接訪問組件的 Fragment 類晋渺。

這個問題我們依舊可以通過反射來解決镰绎,通過來初始化 Fragment 對象并返回給 Activity,在 Actiivty 中將 Fragment 添加到特定位置即可木西。

也可以通過我們的 componentbase 模塊來實現(xiàn)這個功能畴栖,我們可以把 Fragment 的初始化工作放到每一個組件中,模塊需要使用組件的 Fragment 時八千,通過 componentbase 提供的 Service 中的方法來實現(xiàn) Fragment 的初始化吗讶。

這里我們通過第二種方式實現(xiàn)在 Login 組件中提供一個 UserFragment 來演示。

首先恋捆,在 Login 組件中創(chuàng)建 UserFragment关翎,然后在 IAccountService 接口中添加 newUserFragment 方法返回一個 Fragment,在 Login 組件中的 AccountService 和 componentbase 中 IAccountService 的空實現(xiàn)類中實現(xiàn)這個方法鸠信,然后在主模塊中通過 ServiceFactory 獲取 IAccountService 的實現(xiàn)類對象,調(diào)用其 newUserFragment 即可獲取到 UserFragment 的實例论寨。以下是主要代碼:

// componentbase 模塊的 IAccountService 
public interface IAccountService {
    // 其他代碼 ...

    /**
     * 創(chuàng)建 UserFragment
     * @param activity
     * @param containerId
     * @param manager
     * @param bundle
     * @param tag
     * @return
     */
    Fragment newUserFragment(Activity activity, int containerId, FragmentManager manager, Bundle bundle, String tag);
}

// Login 組件中的 AccountService
public class AccountService implements IAccountService {
    // 其他代碼 ...

    @Override
    public Fragment newUserFragment(Activity activity, int containerId, FragmentManager manager, Bundle bundle, String tag) {
        FragmentTransaction transaction = manager.beginTransaction();
        // 創(chuàng)建 UserFragment 實例星立,并添加到 Activity 中
        Fragment userFragment = new UserFragment();
        transaction.add(containerId, userFragment, tag);
        transaction.commit();
        return userFragment;
    }
}

// 主模塊的 FragmentActivity
public class FragmentActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_fragment);
        
        // 通過組件提供的 Service 實現(xiàn) Fragment 的實例化
        ServiceFactory.getInstance().getAccountService().newUserFragment(this, R.id.layout_fragment, getSupportFragmentManager(), null, "");
    }
}

這樣就實現(xiàn)了 Fragment 的實例化,滿足了解耦的要求葬凳,并保證了業(yè)務(wù)分離是不會造成編譯失敗及 App 崩潰绰垂。

六、組件集成調(diào)試

上面解決的幾個問題主要是組件開發(fā)過程中必須要解決的問題火焰,當(dāng)組件開發(fā)完成后我們可能需要將特定幾個組件集成調(diào)試劲装,而不是將所有的組件全部集成進(jìn)行調(diào)試。這時候我們要滿足只集成部分組件時可以編譯通過,不會因為未集成某些組件而出現(xiàn)編譯失敗的問題占业。

其實這個問題我們在解決上面幾個問題的時候就已經(jīng)解決了绒怨。不管是組件間還是模塊與組件間都沒有直接使用其中的類進(jìn)行操作,而是通過 componentbase 模塊中的 Service 來實現(xiàn)的谦疾,而 componentbase 模塊中所有 Service 接口的空實現(xiàn)也保證了即使特定組件沒有初始化南蹂,在其他組件調(diào)用其對應(yīng)方法時也不會出現(xiàn)異常。這種面向接口編程的方式念恍,滿足了我們不管是組件間還是模塊與組件間的相互解耦六剥。

這時候組件化的架構(gòu)圖就成了這樣:


七、組件解耦的目標(biāo)及代碼隔離

解耦目標(biāo)

代碼解耦的首要目標(biāo)就是組件之間的完全隔離峰伙,在開發(fā)過程中我們要時刻牢記疗疟,我們不僅不能直接使用其他組件中的類,最好能根本不了解其中的實現(xiàn)細(xì)節(jié)瞳氓。

代碼隔離

過以上幾個問題的解決方式可以看到策彤,我們在極力的避免組件間及模塊與組件間類的直接引用。不過即使通過 componentbase 中提供 Service 的方式解決了直接引用類的問題顿膨,但是我們在主項目通過 implementation 添加對 login 和 share 組件的依賴后锅锨,在主項目中依舊是可以訪問到 login 和 share 組件中的類的。

這種情況下即使我們的目標(biāo)是面向接口編程恋沃,但是只要能直接訪問到組件中的類必搞,就存在有意或無意的直接通過訪問類的方式使用到組件中的代碼的可能,如果真的出現(xiàn)了這種情況囊咏,我們上面說的解耦就會完全白做了恕洲。

我們希望的組件依賴是只有在打包過程中才能直接引用組件中的類,在開發(fā)階段梅割,所有組件中的類我們都是不可以訪問的霜第。只有實現(xiàn)了這個目標(biāo),才能從根本上杜絕直接引用組件中類的問題户辞。

這個問題我們可以通過 Gradle 提供的方式來解決泌类,Gradle 3.0 提供了新的依賴方式 runtimeOnly ,通過 runtimeOnly 方式依賴時底燎,依賴項僅在運行時對模塊及其消費者可用刃榨,編譯期間依賴項的代碼對其消費者時完全隔離的。

所以我們將主項目中對 Login 組件和 Share 組件的依賴方式修改為 runtimeOnly 的方式就可以解決開發(fā)階段可以直接引用到組件中類的問題双仍。

// 主項目的 build.gradle
dependencies {
    // 其他依賴 ...
    runtimeOnly project(':login')
    runtimeOnly project(':share')
}

解決了代碼隔離的問題枢希,另一個問題就會又浮現(xiàn)出來。組件開發(fā)中不僅要實現(xiàn)代碼的隔離朱沃,還要實現(xiàn)資源文件的隔離苞轿。解決代碼隔離的 runtimeOnly 并不能做到資源隔離茅诱。通過 runtimeOnly 依賴組件后,在主項目中還是可以直接使用到組件中的資源文件搬卒。

為了解決這個問題瑟俭,我們可以在每個組件的 build.gradle 中添加 resourcePrefix 配置來固定這個組件中的資源前綴。不過 resourcePrefix 配置只能限定 res 中 xml 文件中定義的資源秀睛,并不能限定圖片資源尔当,所以我們在往組件中添加圖片資源時要手動限制資源前綴。并將多個組件中都會用到的資源放入 Base 模塊中蹂安。這樣我們就可以在最大限度上實現(xiàn)組件間資源的隔離椭迎。

如果組件配置了 resourcePrefix ,其 xml 中定義的資源沒有以 resourcePrefix 的值作為前綴田盈,在對應(yīng)的 xml 中定義的資源會報紅畜号。resourcePrefix 的值就是指定的組件中 xml 資源的前綴。以 Login 組件為例:

// Login 組件的 build.gradle
android {
    resourcePrefix "login_"
    // 其他配置 ...
}

Login 組件中添加 resourcePrefix 配置后允瞧,我們會發(fā)現(xiàn) res 中 xml 定義的資源都報紅:



而我們修改前綴后則報紅消失简软,顯示恢復(fù)正常:


到這里解決了組件間代碼及資源隔離的問題也就解決了。

八述暂、總結(jié)

解決了上面提到的六個問題痹升,組件化開發(fā)中遇到的主要問題也就全部解決了。其中最關(guān)鍵的就是模塊與組件間的解耦畦韭。在設(shè)計之初也參考了目前主流的幾種組件化方案疼蛾,后來從使用難度、理解難度艺配、維護(hù)難度察郁、擴(kuò)展難度等方面考慮,最終確定了目前的組件化方案转唉。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末皮钠,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子赠法,更是在濱河造成了極大的恐慌麦轰,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,723評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件砖织,死亡現(xiàn)場離奇詭異原朝,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)镶苞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,485評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來鞠评,“玉大人茂蚓,你說我怎么就攤上這事。” “怎么了聋涨?”我有些...
    開封第一講書人閱讀 152,998評論 0 344
  • 文/不壞的土叔 我叫張陵晾浴,是天一觀的道長。 經(jīng)常有香客問我牍白,道長脊凰,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,323評論 1 279
  • 正文 為了忘掉前任茂腥,我火速辦了婚禮狸涌,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘最岗。我一直安慰自己帕胆,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 64,355評論 5 374
  • 文/花漫 我一把揭開白布般渡。 她就那樣靜靜地躺著懒豹,像睡著了一般。 火紅的嫁衣襯著肌膚如雪驯用。 梳的紋絲不亂的頭發(fā)上脸秽,一...
    開封第一講書人閱讀 49,079評論 1 285
  • 那天,我揣著相機(jī)與錄音蝴乔,去河邊找鬼记餐。 笑死,一個胖子當(dāng)著我的面吹牛淘这,可吹牛的內(nèi)容都是我干的剥扣。 我是一名探鬼主播,決...
    沈念sama閱讀 38,389評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼铝穷,長吁一口氣:“原來是場噩夢啊……” “哼钠怯!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起曙聂,我...
    開封第一講書人閱讀 37,019評論 0 259
  • 序言:老撾萬榮一對情侶失蹤晦炊,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后宁脊,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體断国,經(jīng)...
    沈念sama閱讀 43,519評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,971評論 2 325
  • 正文 我和宋清朗相戀三年榆苞,在試婚紗的時候發(fā)現(xiàn)自己被綠了稳衬。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,100評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡坐漏,死狀恐怖薄疚,靈堂內(nèi)的尸體忽然破棺而出碧信,到底是詐尸還是另有隱情,我是刑警寧澤街夭,帶...
    沈念sama閱讀 33,738評論 4 324
  • 正文 年R本政府宣布砰碴,位于F島的核電站,受9級特大地震影響板丽,放射性物質(zhì)發(fā)生泄漏呈枉。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,293評論 3 307
  • 文/蒙蒙 一埃碱、第九天 我趴在偏房一處隱蔽的房頂上張望猖辫。 院中可真熱鬧,春花似錦乃正、人聲如沸住册。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,289評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽荧飞。三九已至,卻和暖如春名党,著一層夾襖步出監(jiān)牢的瞬間叹阔,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,517評論 1 262
  • 我被黑心中介騙來泰國打工传睹, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留耳幢,地道東北人。 一個月前我還...
    沈念sama閱讀 45,547評論 2 354
  • 正文 我出身青樓欧啤,卻偏偏與公主長得像睛藻,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子邢隧,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,834評論 2 345

推薦閱讀更多精彩內(nèi)容

  • 開源ARetrofit也有一段時間了店印,陸續(xù)有用戶反饋希望有文章講述實現(xiàn)的原理,由于本人寫作水平有限一直沒有動筆倒慧。趁...
    iyifei閱讀 2,932評論 1 30
  • ? 上一篇寫了關(guān)于『速讀』的體驗按摘,其中的原理和內(nèi)容并沒有說的特別詳細(xì),因為本意是想從『嘗試者』的角度分享心得纫谅,給大...
    LY加油站閱讀 2,115評論 0 1
  • 年近半百還能趕得上出趟遠(yuǎn)門炫贤,并且是“天府之國”美稱的成都,實在是令人興奮的事情付秕,盡管知道也就是短短的幾天但還是興沖...
    昨夜星雨閱讀 416評論 0 11
  • 大蔥询吴、小蔥俩垃、洋蔥励幼、香蔥……蔥,可以說是我們再熟悉不過的蔬菜了口柳,誰能不認(rèn)識呢?有滑!不過跃闹,今年春天在北京植物園,我陸續(xù)發(fā)...
    溪風(fēng)林語閱讀 657評論 2 4
  • 臘月二十二毛好,是個頂特別的日子—— 這一天望艺,老爸、老媽還有姥姥肌访,同一天過生日找默。是的,你沒聽錯吼驶。 年過花甲的爸媽同年同...
    時慧慧愛物閱讀 901評論 0 4