App Architecture 指南

本指南適用于那些知道構(gòu)建應(yīng)用程序的基礎(chǔ)知識(shí)的開發(fā)人員裳擎,現(xiàn)在想了解構(gòu)建高質(zhì)量生產(chǎn)應(yīng)用程序的最佳實(shí)踐和建議的體系結(jié)構(gòu)粱快。

注意:本指南假定讀者熟悉Android框架。如果您不熟悉應(yīng)用程序開發(fā),請查看入門培訓(xùn)系列,其中包含本指南的必備主題。

1. 應(yīng)用開發(fā)者面臨的常見問題

與傳統(tǒng)的桌面應(yīng)用程序不同击胜,Android應(yīng)用程序的結(jié)構(gòu)要復(fù)雜得多,在大多數(shù)情況下役纹,它們只有一個(gè)快捷方式啟動(dòng)入口點(diǎn)偶摔,并且可以作為一個(gè)整體進(jìn)程運(yùn)行。一個(gè)典型的Android應(yīng)用程序是由多個(gè)應(yīng)用程序組件構(gòu)成的字管,包括活動(dòng)啰挪,片段,服務(wù)嘲叔,內(nèi)容提供者和廣播接收器亡呵。

大多數(shù)這些應(yīng)用程序組件都是在Android操作系統(tǒng)使用的應(yīng)用程序清單中聲明的,以決定如何將您的應(yīng)用程序與其設(shè)備的整體用戶體驗(yàn)相集成硫戈。雖然如前所述锰什,桌面應(yīng)用程序傳統(tǒng)上是以整體的方式運(yùn)行的,但正確編寫的Android應(yīng)用程序需要更靈活丁逝,因?yàn)橛脩艨梢酝ㄟ^設(shè)備上的不同應(yīng)用程序進(jìn)行編程汁胆,不斷切換流程和任務(wù)。

例如霜幼,請考慮在您最喜愛的社交網(wǎng)絡(luò)應(yīng)用程序中分享照片時(shí)會(huì)發(fā)生什么情況嫩码。該應(yīng)用程序觸發(fā)Android操作系統(tǒng)啟動(dòng)相機(jī)應(yīng)用程序來處理請求的相機(jī)意圖。此時(shí)罪既,用戶離開了社交網(wǎng)絡(luò)應(yīng)用铸题,但他們的體驗(yàn)是無縫的。相機(jī)應(yīng)用程序又可能觸發(fā)其他意圖琢感,例如啟動(dòng)文件選擇器丢间,該文件選擇器可能啟動(dòng)另一個(gè)應(yīng)用程序。最終用戶回到社交網(wǎng)絡(luò)應(yīng)用程序并分享照片驹针。此外烘挫,用戶在這個(gè)過程的任何時(shí)候都可能被電話打斷,并在打完電話后回來分享照片柬甥。

在Android中饮六,這種應(yīng)用程序跳轉(zhuǎn)行為很常見其垄,所以您的應(yīng)用程序必須正確處理這些流程。請記住喜滨,移動(dòng)設(shè)備是資源受限捉捅,所以在任何時(shí)候,操作系統(tǒng)可能需要?dú)⑺酪恍?yīng)用程序虽风,以騰出空間給新的棒口。

所有這一切的關(guān)鍵是,您的應(yīng)用程序組件可以單獨(dú)和無序地啟動(dòng)辜膝,并可以在任何時(shí)候由用戶或系統(tǒng)銷毀无牵。由于應(yīng)用程序組件是短暫的,它們的生命周期(創(chuàng)建和銷毀時(shí))不在您的控制之下厂抖,因此您不應(yīng)該在應(yīng)用程序組件中存儲(chǔ)任何應(yīng)用程序數(shù)據(jù)或狀態(tài)茎毁,并且應(yīng)用程序組件不應(yīng)相互依賴。

2.共同的建筑原則

如果您不能使用應(yīng)用程序組件來存儲(chǔ)應(yīng)用程序數(shù)據(jù)和狀態(tài)忱辅,應(yīng)該如何構(gòu)建應(yīng)用程序七蜘?

你應(yīng)該關(guān)注的最重要的事情是在你的應(yīng)用程序中分離關(guān)注點(diǎn)。將所有的代碼寫入一個(gè)Activity>或一個(gè)Fragment是常見的錯(cuò)誤墙懂。任何不處理UI或操作系統(tǒng)交互的代碼都不應(yīng)該在這些類中橡卤。盡可能保持精簡可以避免許多生命周期相關(guān)的問題。不要忘記损搬,你不擁有這些類碧库,它們只是體現(xiàn)操作系統(tǒng)和你的應(yīng)用程序之間的契約的膠水類。Android操作系統(tǒng)可能會(huì)隨時(shí)根據(jù)用戶交互或其他因素(如低內(nèi)存)來銷毀它們巧勤。最好最大限度地減少對他們的依賴嵌灰,以提供可靠的用戶體驗(yàn)。

第二個(gè)重要的原則是你應(yīng)該從一個(gè)模型驅(qū)動(dòng)你的UI颅悉,最好是一個(gè)持久模型沽瞭。持久性是理想的,原因有兩個(gè):如果操作系統(tǒng)破壞您的應(yīng)用程序以釋放資源剩瓶,則您的用戶不會(huì)丟失數(shù)據(jù)秕脓,即使網(wǎng)絡(luò)連接不穩(wěn)定或連接不上,您的應(yīng)用程序也將繼續(xù)工作儒搭。模型是負(fù)責(zé)處理應(yīng)用程序數(shù)據(jù)的組件。它們獨(dú)立于應(yīng)用程序中的視圖和應(yīng)用程序組件芙贫,因此它們與這些組件的生命周期問題是隔離的搂鲫。保持簡單的UI代碼和免費(fèi)的應(yīng)用程序邏輯,使管理更容易磺平。將您的應(yīng)用程序放在模型類上魂仍,具有明確的數(shù)據(jù)管理責(zé)任將使它們可測試拐辽,并使您的應(yīng)用程序保持一致。

3.推薦的應(yīng)用架構(gòu)


在本節(jié)中擦酌,我們將演示如何通過使用用例來構(gòu)建使用體系結(jié)構(gòu)組件的應(yīng)用程序俱诸。

注意:不可能有一種編寫應(yīng)用程序的方法,這對每種情況都是最好的赊舶。這就是說睁搭,這個(gè)推薦的架構(gòu)應(yīng)該是大多數(shù)用例的一個(gè)很好的起點(diǎn)。如果您已經(jīng)有了編寫Android應(yīng)用的好方法笼平,則不需要更改园骆。

想象一下,我們正在構(gòu)建一個(gè)顯示用戶配置文件的用戶界面寓调。該用戶配置文件將使用REST API從我們自己的私人后端獲取锌唾。

3.1構(gòu)建用戶界面>

UI將由一個(gè)片段UserProfileFragment.java及其相應(yīng)的布局文件user_profile_layout.xml組成。
為了驅(qū)動(dòng)用戶界面夺英,我們的數(shù)據(jù)模型需要保存兩個(gè)數(shù)據(jù)元素晌涕。

  • 用戶ID:用戶的標(biāo)識(shí)符。最好使用片段參數(shù)將此信息傳遞到片段中痛悯。如果Android操作系統(tǒng)破壞您的進(jìn)程余黎,這些信息將被保留,以便在您的應(yīng)用下次重新啟動(dòng)時(shí)可用灸蟆。
  • 用戶對象:保存用戶數(shù)據(jù)的POJO驯耻。

我們將創(chuàng)建一個(gè)UserProfileViewModel基于ViewModel的類來保存這些信息。

AViewModel提供了一個(gè)特定的UI組件中的數(shù)據(jù)炒考,如一個(gè)片段或活性可缚,和處理與數(shù)據(jù)處理的部分業(yè)務(wù),如主叫其他組件加載數(shù)據(jù)或轉(zhuǎn)發(fā)的用戶修改的通信斋枢。ViewModel不知道視圖帘靡,并且不受配置更改的影響,例如由于旋轉(zhuǎn)而重新創(chuàng)建活動(dòng)瓤帚。

現(xiàn)在我們有3個(gè)文件描姚。

  • user_profile.xml:屏幕的UI定義。
  • UserProfileViewModel.java:為UI準(zhǔn)備數(shù)據(jù)的類戈次。
  • UserProfileFragment.java:在ViewModel中顯示數(shù)據(jù)并對用戶交互作出反應(yīng)的UI控制器轩勘。

下面是我們的開始的實(shí)現(xiàn)(布局文件為簡單起見被省略):

public class UserProfileViewModel extends ViewModel {
    private String userId;
    private User user;

    public void init(String userId) {
        this.userId = userId;
    }
    public User getUser() {
        return user;
    }
}
public class UserProfileFragment extends Fragment {
    private static final String UID_KEY = "uid";
    private UserProfileViewModel viewModel;

    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        String userId = getArguments().getString(UID_KEY);
        viewModel = ViewModelProviders.of(this).get(UserProfileViewModel.class);
        viewModel.init(userId);
    }

    @Override
    public View onCreateView(LayoutInflater inflater,
                @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.user_profile, container, false);
    }
}

現(xiàn)在,我們有這三個(gè)代碼模塊怯邪,我們?nèi)绾芜B接它們绊寻?畢竟,當(dāng)ViewModel的用戶字段被設(shè)置,我們需要一種方式來通知用戶界面澄步。這是LiveData類的地方冰蘑。

LiveData是一個(gè)可觀察的數(shù)據(jù)持有者。它允許應(yīng)用程序中的組件觀察LiveData 對象的更改村缸,而不會(huì)在它們之間創(chuàng)建明確的和嚴(yán)格的依賴關(guān)系路徑祠肥。LiveData還感知您的應(yīng)用程序組件(活動(dòng),片段梯皿,服務(wù))的生命周期狀態(tài)仇箱,并做正確的事情來防止對象泄漏,使您的應(yīng)用程序不會(huì)消耗更多的內(nèi)存索烹。

注意:如果您已經(jīng)在使用類似RxJavaAgera的庫工碾,則可以繼續(xù)使用它們而不是LiveData。但是百姓,當(dāng)您使用它們或其他方法時(shí)渊额,請確保正確處理生命周期,以便在相關(guān)的LifecycleOwner停止時(shí)停止數(shù)據(jù)流垒拢,并在銷毀LifecycleOwner時(shí)銷毀數(shù)據(jù)流旬迹。您還可以添加<android.arch.lifecycle:reactivestreams工件以將LiveData與另一個(gè)反應(yīng)流庫(例如RxJava2)一起使用。

現(xiàn)在我們用LiveData<User>替換UserProfileViewModel中的User字段求类,以便在數(shù)據(jù)更新時(shí)通知片段奔垦。LiveData重要的是,它是生命周期感知尸疆,并將自動(dòng)清理引用時(shí)椿猎,不再需要。

public class UserProfileViewModel extends ViewModel {
    ...
    private User user;
    private LiveData<User> user;
    public LiveData<User> getUser() {
        return user;
    }
}

現(xiàn)在我們修改UserProfileFragment觀察數(shù)據(jù)并更新UI寿弱。

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    viewModel.getUser().observe(this, user -> {
      // update UI
    });
}

每次更新用戶數(shù)據(jù)時(shí)犯眠,都會(huì)調(diào)用onChanged回調(diào),并刷新UI症革。

如果您熟悉使用可觀察回調(diào)的其他庫筐咧,您可能已經(jīng)意識(shí)到,我們不必重寫片段的onStop()方法來停止觀察數(shù)據(jù)噪矛。這對于LiveData來說是不必要的量蕊,因?yàn)樗巧芷诟兄模@意味著它不會(huì)調(diào)用回調(diào)艇挨,除非片段處于活動(dòng)狀態(tài)(已接收onStart()但未接收onStop())残炮。當(dāng)數(shù)據(jù)片段收到onDestroy()時(shí),LiveData也會(huì)自動(dòng)移除觀察者缩滨。

我們也沒有做任何特殊的處理配置變化(例如吉殃,用戶旋轉(zhuǎn)屏幕)辞居。當(dāng)配置改變時(shí),ViewModel會(huì)自動(dòng)恢復(fù)蛋勺,所以一旦新的片段生效,它將接收到相同的ViewModel實(shí)例鸠删,并且回調(diào)將被當(dāng)前數(shù)據(jù)立即調(diào)用抱完。這就是ViewModel不能直接引用Views的原因。他們可以超越View的生命周期刃泡。請參閱ViewModel的生命周期巧娱。

3. 2提取數(shù)據(jù)

現(xiàn)在我們已經(jīng)將ViewModel連接到了片段,但是ViewModel如何獲取用戶數(shù)據(jù)呢烘贴?在這個(gè)例子中禁添,我們假設(shè)我們的后端提供了一個(gè)REST API。我們將使用 Retrofit庫來訪問我們的后端桨踪,盡管您可以自由使用不同的庫來達(dá)到同樣的目的老翘。

以下是我們Webservice與后端進(jìn)行通信的改造:

public interface Webservice {
    /**
     * @GET declares an HTTP GET request
     * @Path("user") annotation on the userId parameter marks it as a
     * replacement for the {user} placeholder in the @GET path
     */
    @GET("/users/{user}")
    Call<User> getUser(@Path("user") String userId);
}

天真的實(shí)現(xiàn)ViewModel可以直接調(diào)用Webservice 來獲取數(shù)據(jù)并將其分配給用戶對象。即使它可行锻离,您的應(yīng)用程序也將難以維持铺峭。它給ViewModel類提供了太多的責(zé)任,這違背了前面提到的關(guān)注點(diǎn)分離原則此外汽纠,ViewModel的范圍與ActivityFragment生命周期相關(guān)聯(lián),所以當(dāng)生命周期完成時(shí)丟失所有的數(shù)據(jù)是一個(gè)糟糕的用戶體驗(yàn)。相反搀玖,我們的ViewModel將這個(gè)工作委托給一個(gè)新的Repository模塊豌拙。

存儲(chǔ)庫模塊負(fù)責(zé)處理數(shù)據(jù)操作。他們?yōu)閼?yīng)用程序的其余部分提供了一個(gè)干凈的API碴犬。他們知道從何處獲取數(shù)據(jù)以及在更新數(shù)據(jù)時(shí)調(diào)用哪些API絮宁。您可以將它們視為不同數(shù)據(jù)源(持久模型,Web服務(wù)翅敌,緩存等)之間的中介羞福。

下面的UserRepository類使用WebService獲取用戶數(shù)據(jù)項(xiàng)。

public class UserRepository {
    private Webservice webservice;
    // ...
    public LiveData<User> getUser(int userId) {
        // This is not an optimal implementation, we'll fix it below
        final MutableLiveData<User> data = new MutableLiveData<>();
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                // error case is left out for brevity
                data.setValue(response.body());
            }
        });
        return data;
    }
}

即使存儲(chǔ)庫模塊看起來不必要蚯涮,它也是一個(gè)重要的目的治专。它從應(yīng)用程序的其余部分提取數(shù)據(jù)源。現(xiàn)在我們的ViewModel不知道數(shù)據(jù)是由哪個(gè)Webservice取得的遭顶,這意味著我們可以根據(jù)需要將它交換為其他的實(shí)現(xiàn)张峰。

注意:為了簡單起見,我們忽略了網(wǎng)絡(luò)錯(cuò)誤的情況棒旗。有關(guān)公開錯(cuò)誤和加載狀態(tài)的替代實(shí)現(xiàn)喘批,請參閱 附錄:公開網(wǎng)絡(luò)狀態(tài)撩荣。

管理組件之間的依賴關(guān)系:

上面的UserRepository類需要一個(gè)Webservice工作的實(shí)例。它可以簡單地創(chuàng)建它饶深,但要做到這一點(diǎn)餐曹,它也需要知道Webservice類<的依賴關(guān)系來構(gòu)造它。這會(huì)使代碼復(fù)雜化和復(fù)制(例如敌厘,每個(gè)需要Webservice實(shí)例的類都需要知道如何用它的依賴來構(gòu)造它)台猴。另外,UserRepository可能不是唯一需要Webservice的類俱两。如果每個(gè)類創(chuàng)建一個(gè)新的WebService饱狂,這將是非常重的資源。
有兩種模式可以用來解決這個(gè)問題:

  • 依賴注入:依賴注入允許類在不構(gòu)造它們的情況下定義它們的依賴關(guān)系宪彩。在運(yùn)行時(shí)休讳,另一個(gè)類負(fù)責(zé)提供這些依賴關(guān)系。我們推薦Google的Dagger 2庫在Android應(yīng)用程序中實(shí)現(xiàn)依賴注入尿孔。Dagger 2通過遍歷依賴關(guān)系樹來自動(dòng)構(gòu)造對象俊柔,并為依賴關(guān)系提供編譯時(shí)間保證。
  • 服務(wù)定位器:服務(wù)定位器提供了一個(gè)注冊表纳猫,類可以獲得它們的依賴而不是構(gòu)建它們婆咸。實(shí)現(xiàn)起來比依賴注入(DI)更容易,所以如果你不熟悉DI芜辕,可以使用Service Locator尚骄。

這些模式允許您擴(kuò)展代碼,因?yàn)樗鼈兲峁┝擞糜诠芾硪蕾囮P(guān)系的清晰模式侵续,無需復(fù)制代碼或增加復(fù)雜性倔丈。他們兩人也允許交換實(shí)現(xiàn)測試;這是使用它們的主要好處之一状蜗。

在這個(gè)例子中需五,我們將使用Dagger 2來管理依賴關(guān)系。

3.3 連接ViewModel和存儲(chǔ)庫

現(xiàn)在我們修改我們UserProfileViewModel的存儲(chǔ)庫轧坎。

public class UserProfileViewModel extends ViewModel {
    private LiveData<User> user;
    private UserRepository userRepo;

    @Inject // UserRepository parameter is provided by Dagger 2
    public UserProfileViewModel(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    public void init(String userId) {
        if (this.user != null) {
            // ViewModel is created per Fragment so
            // we know the userId won't change
            return;
        }
        user = userRepo.getUser(userId);
    }

    public LiveData<User> getUser() {
        return this.user;
    }
}
3.4緩存數(shù)據(jù)

上面的存儲(chǔ)庫實(shí)現(xiàn)對抽象調(diào)用Web服務(wù)是有好處的宏邮,但是因?yàn)樗灰蕾囉谝粋€(gè)數(shù)據(jù)源,所以它不是很有用缸血。

上面實(shí)現(xiàn)UserRepository的問題是蜜氨,在獲取數(shù)據(jù)之后,它不保存在任何地方捎泻。如果用戶離開 UserProfileFragment并返回飒炎,應(yīng)用程序?qū)⒅匦芦@取數(shù)據(jù)。這是不好的笆豁,原因有兩個(gè):浪費(fèi)寶貴的網(wǎng)絡(luò)帶寬并強(qiáng)制用戶等待新的查詢完成郎汪。為了解決這個(gè)問題赤赊,我們將添加一個(gè)新的數(shù)據(jù)源,我們UserRepository將緩存User內(nèi)存中的對象煞赢。

@Singleton  // informs Dagger that this class should be constructed once
public class UserRepository {
    private Webservice webservice;
    // simple in memory cache, details omitted for brevity
    private UserCache userCache;
    public LiveData<User> getUser(String userId) {
        LiveData<User> cached = userCache.get(userId);
        if (cached != null) {
            return cached;
        }

        final MutableLiveData<User> data = new MutableLiveData<>();
        userCache.put(userId, data);
        // this is still suboptimal but better than before.
        // a complete implementation must also handle the error cases.
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                data.setValue(response.body());
            }
        });
        return data;
    }
}

3.5保持?jǐn)?shù)據(jù)

在我們當(dāng)前的實(shí)現(xiàn)中抛计,如果用戶旋轉(zhuǎn)屏幕或離開并返回到應(yīng)用程序,則現(xiàn)有UI將立即可見耕驰,因?yàn)榇鎯?chǔ)庫從內(nèi)存中緩存中檢索數(shù)據(jù)爷辱。但是,如果用戶離開應(yīng)用程序朦肘,并在Android操作系統(tǒng)殺死該進(jìn)程后數(shù)小時(shí)后回來,會(huì)發(fā)生什么双饥?

在目前的實(shí)現(xiàn)中媒抠,我們將需要從網(wǎng)絡(luò)上重新獲取數(shù)據(jù)。這不僅是一個(gè)糟糕的用戶體驗(yàn)咏花,而且會(huì)浪費(fèi)趴生,因?yàn)樗鼤?huì)使用移動(dòng)數(shù)據(jù)重新獲取相同的數(shù)據(jù)。您可以簡單地通過緩存Web請求來解決這個(gè)問題昏翰,但是會(huì)產(chǎn)生新的問題苍匆。如果相同的用戶數(shù)據(jù)顯示出另一種類型的請求(例如,獲取朋友列表)棚菊,會(huì)發(fā)生什么情況浸踩?那么你的應(yīng)用程序可能會(huì)顯示不一致的數(shù)據(jù),這是一個(gè)混亂的用戶體驗(yàn)统求。例如检碗,由于好友列表請求和用戶請求可以在不同的時(shí)間執(zhí)行,所以相同用戶的數(shù)據(jù)可能會(huì)以不同的方式顯示码邻。您的應(yīng)用需要合并它們以避免顯示不一致的數(shù)據(jù)折剃。

處理這個(gè)問題的正確方法是使用持久模型。這是<Room持久性庫來大顯身手的地方像屋。

Room是一個(gè)對象映射庫怕犁,提供本地?cái)?shù)據(jù)持久性和最小的樣板代碼。在編譯時(shí)己莺,它會(huì)根據(jù)模式驗(yàn)證每個(gè)查詢奏甫,在SQL查詢導(dǎo)致編譯錯(cuò)誤時(shí)斷開,而不是運(yùn)行時(shí)失敗篇恒。Room抽象出一些使用原始SQL表和查詢的底層實(shí)現(xiàn)細(xì)節(jié)扶檐。它還允許觀察對數(shù)據(jù)庫數(shù)據(jù)(包括集合和連接查詢)的更改,通過LiveData對象公開這些更改胁艰。另外款筑,它明確定義了解決常見問題的線程約束智蝠,例如訪問主線程上的存儲(chǔ)。

注意:如果您的應(yīng)用程序已經(jīng)使用另一個(gè)持久性解決方案(如SQLite對象關(guān)系映射(ORM))奈梳,則不需要使用Room替換現(xiàn)有的解決方案杈湾。但是,如果您正在編寫新的應(yīng)用程序或重構(gòu)現(xiàn)有的應(yīng)用程序攘须,我們建議使用Room來保存應(yīng)用程序的數(shù)據(jù)漆撞。這樣,您可以利用庫的抽象和查詢驗(yàn)證功能于宙。

要使用Room浮驳,我們需要定義我們的本地模式。首先捞魁,@Entity 注釋User 將類標(biāo)記為數(shù)據(jù)庫中的表至会。

@Entity
class User {
  @PrimaryKey
  private int id;
  private String name;
  private String lastName;
  // getters and setters for fields
}

然后,您的應(yīng)用程序通過擴(kuò)展RoomDatabase 來創(chuàng)建一個(gè)數(shù)據(jù)庫類:

@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}

注意這MyDatabase是抽象的谱俭。Room自動(dòng)提供一個(gè)實(shí)施奉件。有關(guān)詳細(xì)信息,請參見房間文檔.

現(xiàn)在我們需要一種將用戶數(shù)據(jù)插入數(shù)據(jù)庫的方法昆著。為此县貌,我們將創(chuàng)建一個(gè)數(shù)據(jù)訪問對象(DAO)

@Dao
public interface UserDao {
    @Insert(onConflict = REPLACE)
    void save(User user);
    @Query("SELECT * FROM user WHERE id = :userId")
    LiveData<User> load(String userId);
}

然后凑懂,從我們的數(shù)據(jù)庫類中引用DAO煤痕。

@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

請注意,該load方法返回一個(gè)LiveData<User>征候。Room知道數(shù)據(jù)庫何時(shí)被修改杭攻,當(dāng)數(shù)據(jù)改變時(shí)它會(huì)自動(dòng)通知所有活動(dòng)的觀察者。因?yàn)樗褂玫氖?code>LiveData疤坝,所以這將是有效的兆解,因?yàn)橹挥兄辽儆幸粋€(gè)活動(dòng)的觀察者才會(huì)更新數(shù)據(jù)。

注意:Room根據(jù)表格修改檢查失效跑揉,這意味著它可能發(fā)送誤報(bào)通知锅睛。

現(xiàn)在我們可以修改我們UserRepository來合并房間數(shù)據(jù)源。

@Singleton
public class UserRepository {
    private final Webservice webservice;
    private final UserDao userDao;
    private final Executor executor;

    @Inject
    public UserRepository(Webservice webservice, UserDao userDao, Executor executor) {
        this.webservice = webservice;
        this.userDao = userDao;
        this.executor = executor;
    }

    public LiveData<User> getUser(String userId) {
        refreshUser(userId);
        // return a LiveData directly from the database.
        return userDao.load(userId);
    }

    private void refreshUser(final String userId) {
        executor.execute(() -> {
            // running in a background thread
            // check if user was fetched recently
            boolean userExists = userDao.hasUser(FRESH_TIMEOUT);
            if (!userExists) {
                // refresh the data
                Response response = webservice.getUser(userId).execute();
                // TODO check for error etc.
                // Update the database.The LiveData will automatically refresh so
                // we don't need to do anything else here besides updating the database
                userDao.save(response.body());
            }
        });
    }
}

請注意历谍,盡管我們改變了 UserRepository中數(shù)據(jù)來源现拒,我們并不需要改變我們的UserProfileViewModelUserProfileFragment。這是抽象提供的靈活性望侈。這對于測試來說也很棒印蔬,因?yàn)槟憧梢蕴峁┮粋€(gè)假的UserRepository 在測試你UserProfileViewModel的時(shí)候。

現(xiàn)在我們的代碼是完整的脱衙。如果用戶以后回到相同的用戶界面侥猬,他們會(huì)立即看到用戶信息例驹,因?yàn)槲覀兂志没恕M瑫r(shí)退唠,如果數(shù)據(jù)陳舊鹃锈,我們的倉庫將在后臺(tái)更新數(shù)據(jù)。當(dāng)然瞧预,根據(jù)您的使用情況屎债,如果數(shù)據(jù)太舊,您可能不希望顯示持久數(shù)據(jù)垢油。

在一些使用情況下盆驹,如下拉刷新時(shí),UI給用戶展示是否正在進(jìn)行網(wǎng)絡(luò)操作是非常重要的滩愁。將UI操作與實(shí)際數(shù)據(jù)分開是一種很好的做法召娜,因?yàn)樗赡芤蚋鞣N原因而更新(例如,如果我們獲取朋友列表惊楼,同一用戶可能會(huì)再次觸發(fā)LiveData<User>更新)。從用戶界面的角度來看秸讹,有一個(gè)請求正在執(zhí)行的事實(shí)只是另一個(gè)數(shù)據(jù)點(diǎn)檀咙,類似于任何其他數(shù)據(jù)(如User對象)。

這個(gè)用例有兩種常見的解決方案:

  • 更改getUser為返回包含網(wǎng)絡(luò)操作狀態(tài)的LiveData璃诀。提供了一個(gè)示例實(shí)現(xiàn)在附錄:公開網(wǎng)絡(luò)狀態(tài)部分弧可。
  • 在存儲(chǔ)庫類中提供另一個(gè)可以返回用戶刷新狀態(tài)的公共函數(shù)。如果只想響應(yīng)顯式的用戶操作(如下拉刷新)來顯示網(wǎng)絡(luò)狀態(tài)劣欢,則此選項(xiàng)更好棕诵。

單一的事實(shí)來源
不同的REST API端點(diǎn)通常返回相同的數(shù)據(jù)。例如凿将,如果我們的后端擁有另一個(gè)返回朋友列表的端點(diǎn)校套,則同一個(gè)用戶對象可能來自兩個(gè)不同的API端點(diǎn),也許粒度不同牧抵。如果UserRepository原樣返回Webservice請求的響應(yīng)笛匙,我們的UI可能會(huì)顯示不一致的數(shù)據(jù),因?yàn)樵谶@些請求之間數(shù)據(jù)可能在服務(wù)器端發(fā)生更改犀变。這就是為什么在UserRepository實(shí)現(xiàn)中妹孙,Web服務(wù)回調(diào)只是將數(shù)據(jù)保存到數(shù)據(jù)庫中。然后获枝,對數(shù)據(jù)庫的更改將觸發(fā)活躍LiveData的回調(diào)蠢正。

在這個(gè)模型中,數(shù)據(jù)庫充當(dāng)真相的單一來源省店,應(yīng)用程序的其他部分通過存儲(chǔ)庫訪問它嚣崭。無論您使用磁盤緩存笨触,我們都建議您的存儲(chǔ)庫將數(shù)據(jù)源指定為其余應(yīng)用程序的單一來源。

3.6測試

我們已經(jīng)提到分離的好處之一就是可測試性有鹿。讓我們看看我們?nèi)绾螠y試每個(gè)代碼模塊旭旭。

  • User Interface & Interactions:這將是您唯一需要進(jìn)行 Android UI Instrumentation測試的時(shí)間。測試UI代碼的最好方法是創(chuàng)建一個(gè) Espresso測試葱跋。<您可以創(chuàng)建片段并為其提供一個(gè)模擬的ViewModel持寄。<由于該片段只與ViewModel交談,所以嘲笑它將足以完全測試這個(gè)UI娱俺。

  • ViewModel:可以使用JUnit測試來測試ViewModel稍味。你只需要模擬UserRepository`測試它。

  • UserRepository:您也可以使用JUnit測試來測試UserRepository荠卷。你需要模擬Webservice和DAO模庐。您可以測試它是否做出正確的Web服務(wù)調(diào)用,將結(jié)果保存到數(shù)據(jù)庫中油宜,如果數(shù)據(jù)已緩存且最新掂碱,則不會(huì)發(fā)出任何不必要的請求。由于這兩個(gè)WebserviceUserDao的界面慎冤,你可以模擬他們或創(chuàng)建更復(fù)雜的測試案例假冒實(shí)現(xiàn)..

  • UserDao:測試DAO類的推薦方法是使用儀器測試疼燥。由于這些儀器測試不需要任何用戶界面,他們?nèi)匀粫?huì)運(yùn)行得很快蚁堤。對于每個(gè)測試醉者,您可以創(chuàng)建一個(gè)內(nèi)存數(shù)據(jù)庫,以確保測試沒有任何副作用(如更改磁盤上的數(shù)據(jù)庫文件)披诗。
    Room也允許指定數(shù)據(jù)庫的實(shí)現(xiàn)撬即,所以你可以通過提供JUnit實(shí)現(xiàn) SupportSQLiteOpenHelper來測試它。通常不建議使用這種方法呈队,因?yàn)樵O(shè)備上運(yùn)行的SQLite版本可能與主機(jī)上的SQLite版本不同剥槐。

  • Webservice:使測試獨(dú)立于外部是很重要的,所以即使你的Webservice測試也應(yīng)該避免對后端進(jìn)行網(wǎng)絡(luò)調(diào)用掂咒。有很多庫類可以幫助你才沧。例如,MockWebServer 是一個(gè)偉大的庫绍刮,可以幫助您為測試創(chuàng)建一個(gè)假的本地服務(wù)器温圆。

  • Testing Artifacts體系結(jié)構(gòu)組件提供了一個(gè)Maven工件來控制其后臺(tái)線程。在android.arch.core:core-testing神器內(nèi)部孩革,有2個(gè)JUnit規(guī)則:

    • InstantTaskExecutorRule:此規(guī)則可用于強(qiáng)制架構(gòu)組件立即在調(diào)用線程上執(zhí)行任何后臺(tái)操作岁歉。
    • CountingTaskExecutorRule:此規(guī)則可用于檢測測試,以等待體系結(jié)構(gòu)組件的后臺(tái)操作或?qū)⑵渥鳛殚e置資源連接到Espresso。
3.7 最終的體系結(jié)構(gòu)<

下圖顯示了我們推薦的體系結(jié)構(gòu)中的所有模塊以及它們?nèi)绾蜗嗷ソ换ィ?/p>

image.png
4.指導(dǎo)原則

編程是一個(gè)創(chuàng)造性的領(lǐng)域锅移,構(gòu)建Android應(yīng)用程序不是一個(gè)例外熔掺。解決問題的方法有很多種,可以在多個(gè)活動(dòng)或片段之間傳遞數(shù)據(jù)非剃,檢索遠(yuǎn)程數(shù)據(jù)并將其保存在本地以進(jìn)行脫機(jī)模式置逻,也可以使用許多其他常見應(yīng)用程序遇到的情況。

雖然以下建議不是強(qiáng)制性的备绽,但是我們的經(jīng)驗(yàn)是券坞,遵循這些建議將使您的代碼基礎(chǔ)更加健壯,可測試和可維護(hù)肺素。

  • 您在清單中定義的入口點(diǎn)(活動(dòng)恨锚,服務(wù),廣播接收器等)不是數(shù)據(jù)的來源倍靡。相反猴伶,他們只應(yīng)該協(xié)調(diào)與該入口點(diǎn)相關(guān)的數(shù)據(jù)子集。由于每個(gè)應(yīng)用程序組件的壽命相當(dāng)短塌西,這取決于用戶與設(shè)備的交互以及運(yùn)行時(shí)的整體當(dāng)前運(yùn)行狀況他挎,因此您不希望這些入口點(diǎn)中的任何一個(gè)成為數(shù)據(jù)源。

  • 無情地在應(yīng)用程序的各個(gè)模塊之間創(chuàng)建明確界定的責(zé)任捡需。例如雇盖,不要將從網(wǎng)絡(luò)加載數(shù)據(jù)的代碼跨代碼庫中的多個(gè)類或包傳播。同樣栖忠,不要把不相關(guān)的職責(zé) - 比如數(shù)據(jù)緩存和數(shù)據(jù)綁定 - 放到同一個(gè)類中。

  • 盡可能少地從每個(gè)模塊公開贸街。不要試圖創(chuàng)建“只有那一個(gè)”的快捷方式庵寞,從一個(gè)模塊公開內(nèi)部實(shí)現(xiàn)細(xì)節(jié)。您可能在短期內(nèi)獲得一些時(shí)間薛匪,但隨著您的代碼庫的發(fā)展捐川,您將多次支付技術(shù)債務(wù)。

  • 在定義模塊之間的交互時(shí)逸尖,請考慮如何使每個(gè)模塊獨(dú)立可測試古沥。例如,如果有一個(gè)定義良好的API從網(wǎng)絡(luò)中獲取數(shù)據(jù)娇跟,將會(huì)更容易測試將數(shù)據(jù)保存在本地?cái)?shù)據(jù)庫中的模塊岩齿。相反,如果將這兩個(gè)模塊的邏輯混合在一起苞俘,或者在整個(gè)代碼庫中撒上網(wǎng)絡(luò)代碼盹沈,那么要測試就更加困難了。

  • 你的應(yīng)用程序的核心是什么讓它從其他中脫穎而出吃谣。不要花費(fèi)時(shí)間重復(fù)發(fā)明輪子乞封,或者一次又一次地寫出相同的樣板代碼做裙。相反,將精力集中在讓您的應(yīng)用獨(dú)特的東西上肃晚,讓Android Architecture組件和其他推薦的庫處理重復(fù)的樣板锚贱。

  • 保持盡可能多的相關(guān)和新鮮的數(shù)據(jù),以便您的應(yīng)用程序在設(shè)備處于離線模式時(shí)可用关串。雖然您可以享受持續(xù)高速的連接拧廊,但用戶可能不會(huì)。

  • 您的存儲(chǔ)庫應(yīng)該指定一個(gè)數(shù)據(jù)源作為單一的事實(shí)來源悍缠。無論何時(shí)您的應(yīng)用程序需要訪問這些數(shù)據(jù)卦绣,都應(yīng)該始終從單一的事實(shí)源頭開始。有關(guān)更多信息飞蚓,請參閱單一來源的真相

5.附錄:揭露網(wǎng)絡(luò)狀態(tài)

在上面推薦的應(yīng)用程序體系結(jié)構(gòu)部分滤港,我們故意省略網(wǎng)絡(luò)錯(cuò)誤和加載狀態(tài),以保持樣本簡單趴拧。
在本節(jié)中溅漾,我們演示一種使用Resource類來公開網(wǎng)絡(luò)狀態(tài)的方法來封裝數(shù)據(jù)及其狀態(tài)。
以下是一個(gè)示例實(shí)現(xiàn):

//a generic class that describes a data with a status
public class Resource<T> {
    @NonNull public final Status status;
    @Nullable public final T data;
    @Nullable public final String message;
    private Resource(@NonNull Status status, @Nullable T data, @Nullable String message) {
        this.status = status;
        this.data = data;
        this.message = message;
    }

    public static <T> Resource<T> success(@NonNull T data) {
        return new Resource<>(SUCCESS, data, null);
    }

    public static <T> Resource<T> error(String msg, @Nullable T data) {
        return new Resource<>(ERROR, data, msg);
    }

    public static <T> Resource<T> loading(@Nullable T data) {
        return new Resource<>(LOADING, data, null);
    }
}

因?yàn)樵趶拇疟P顯示數(shù)據(jù)時(shí)從網(wǎng)絡(luò)加載數(shù)據(jù)是一個(gè)常見的用例著榴,我們將創(chuàng)建一個(gè)NetworkBoundResource可以在多個(gè)地方重復(fù)使用的幫助類<添履。以下是NetworkBoundResource決策樹:

image.png

它通過觀察資源的數(shù)據(jù)庫開始。當(dāng)條目從數(shù)據(jù)庫中第一次加載時(shí)脑又,NetworkBoundResource檢查結(jié)果是否足夠好以便分派/或從網(wǎng)絡(luò)中獲取暮胧。請注意,這兩種情況可能同時(shí)發(fā)生问麸,因?yàn)槟赡芟M趶木W(wǎng)絡(luò)更新緩存數(shù)據(jù)時(shí)顯示緩存的數(shù)據(jù)往衷。
如果網(wǎng)絡(luò)請求成功完成,則將響應(yīng)保存到數(shù)據(jù)庫中并重新初始化流严卖。如果網(wǎng)絡(luò)請求失敗席舍,我們直接發(fā)送失敗。

注意:在將新數(shù)據(jù)保存到磁盤之后哮笆,我們會(huì)重新初始化數(shù)據(jù)庫中的數(shù)據(jù)流来颤,但通常我們不需要這樣做,因?yàn)閿?shù)據(jù)庫將分發(fā)變化稠肘。另一方面福铅,依靠數(shù)據(jù)庫來分發(fā)變化將有不好的副作用,它可能會(huì)中斷项阴,因?yàn)槿绻麛?shù)據(jù)沒有變化本讥,數(shù)據(jù)庫不會(huì)分發(fā)變化。我們也不希望發(fā)送從網(wǎng)絡(luò)獲得的結(jié)果,因?yàn)檫@將違背單一的事實(shí)來源(也許在數(shù)據(jù)庫中有觸發(fā)器會(huì)改變保存的值)拷沸。我們也不希望分發(fā)了SUCCESS但是沒有新的數(shù)據(jù)色查,這就向客戶發(fā)送了錯(cuò)誤的信息。

以下是NetworkBoundResource類為子類提供的公共API:

// ResultType: Type for the Resource data
// RequestType: Type for the API response
public abstract class NetworkBoundResource<ResultType, RequestType> {
    // Called to save the result of the API response into the database
    @WorkerThread
    protected abstract void saveCallResult(@NonNull RequestType item);

    // Called with the data in the database to decide whether it should be
    // fetched from the network.
    @MainThread
    protected abstract boolean shouldFetch(@Nullable ResultType data);

    // Called to get the cached data from the database
    @NonNull @MainThread
    protected abstract LiveData<ResultType> loadFromDb();

    // Called to create the API call.
    @NonNull @MainThread
    protected abstract LiveData<ApiResponse<RequestType>> createCall();

    // Called when the fetch fails. The child class may want to reset components
    // like rate limiter.
    @MainThread
    protected void onFetchFailed() {
    }

    // returns a LiveData that represents the resource, implemented
    // in the base class.
    public final LiveData<Resource<ResultType>> getAsLiveData();
}

請注意撞芍,上面的類定義了兩個(gè)類型參數(shù)(ResultType秧了, RequestType),因?yàn)閺腁PI返回的數(shù)據(jù)類型可能與本地使用的數(shù)據(jù)類型不匹配序无。

另請注意验毡,上面的代碼ApiResponse用于網(wǎng)絡(luò)請求。 ApiResponse是一個(gè)簡單的Retrofit2.Call類包裝帝嗡,將其響應(yīng)轉(zhuǎn)換為LiveData晶通。

以下是NetworkBoundResource類的其余實(shí)現(xiàn):

public abstract class NetworkBoundResource<ResultType, RequestType> {
    private final MediatorLiveData<Resource<ResultType>> result = new MediatorLiveData<>();

    @MainThread
    NetworkBoundResource() {
        result.setValue(Resource.loading(null));
        LiveData<ResultType> dbSource = loadFromDb();
        result.addSource(dbSource, data -> {
            result.removeSource(dbSource);
            if (shouldFetch(data)) {
                fetchFromNetwork(dbSource);
            } else {
                result.addSource(dbSource,
                        newData -> result.setValue(Resource.success(newData)));
            }
        });
    }

    private void fetchFromNetwork(final LiveData<ResultType> dbSource) {
        LiveData<ApiResponse<RequestType>> apiResponse = createCall();
        // we re-attach dbSource as a new source,
        // it will dispatch its latest value quickly
        result.addSource(dbSource,
                newData -> result.setValue(Resource.loading(newData)));
        result.addSource(apiResponse, response -> {
            result.removeSource(apiResponse);
            result.removeSource(dbSource);
            //noinspection ConstantConditions
            if (response.isSuccessful()) {
                saveResultAndReInit(response);
            } else {
                onFetchFailed();
                result.addSource(dbSource,
                        newData -> result.setValue(
                                Resource.error(response.errorMessage, newData)));
            }
        });
    }

    @MainThread
    private void saveResultAndReInit(ApiResponse<RequestType> response) {
        new AsyncTask<Void, Void, Void>() {

            @Override
            protected Void doInBackground(Void... voids) {
                saveCallResult(response.body);
                return null;
            }

            @Override
            protected void onPostExecute(Void aVoid) {
                // we specially request a new live data,
                // otherwise we will get immediately last cached value,
                // which may not be updated with latest results received from network.
                result.addSource(loadFromDb(),
                        newData -> result.setValue(Resource.success(newData)));
            }
        }.execute();
    }

    public final LiveData<Resource<ResultType>> getAsLiveData() {
        return result;
    }
}

現(xiàn)在,我們可以使用NetworkBoundResource將我們的磁盤和網(wǎng)絡(luò)綁定的User實(shí)現(xiàn)寫入到存儲(chǔ)庫中哟玷。

class UserRepository {
    Webservice webservice;
    UserDao userDao;

    public LiveData<Resource<User>> loadUser(final String userId) {
        return new NetworkBoundResource<User,User>() {
            @Override
            protected void saveCallResult(@NonNull User item) {
                userDao.insert(item);
            }

            @Override
            protected boolean shouldFetch(@Nullable User data) {
                return rateLimiter.canFetch(userId) && (data == null || !isFresh(data));
            }

            @NonNull @Override
            protected LiveData<User> loadFromDb() {
                return userDao.load(userId);
            }

            @NonNull @Override
            protected LiveData<ApiResponse<User>> createCall() {
                return webservice.getUser(userId);
            }
        }.getAsLiveData();
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末狮辽,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子巢寡,更是在濱河造成了極大的恐慌喉脖,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,686評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件抑月,死亡現(xiàn)場離奇詭異树叽,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)谦絮,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,668評論 3 385
  • 文/潘曉璐 我一進(jìn)店門题诵,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人层皱,你說我怎么就攤上這事仇轻。” “怎么了奶甘?”我有些...
    開封第一講書人閱讀 158,160評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長祭椰。 經(jīng)常有香客問我臭家,道長,這世上最難降的妖魔是什么方淤? 我笑而不...
    開封第一講書人閱讀 56,736評論 1 284
  • 正文 為了忘掉前任钉赁,我火速辦了婚禮,結(jié)果婚禮上携茂,老公的妹妹穿的比我還像新娘你踩。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,847評論 6 386
  • 文/花漫 我一把揭開白布带膜。 她就那樣靜靜地躺著吩谦,像睡著了一般。 火紅的嫁衣襯著肌膚如雪膝藕。 梳的紋絲不亂的頭發(fā)上式廷,一...
    開封第一講書人閱讀 50,043評論 1 291
  • 那天,我揣著相機(jī)與錄音芭挽,去河邊找鬼滑废。 笑死,一個(gè)胖子當(dāng)著我的面吹牛袜爪,可吹牛的內(nèi)容都是我干的蠕趁。 我是一名探鬼主播,決...
    沈念sama閱讀 39,129評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼辛馆,長吁一口氣:“原來是場噩夢啊……” “哼俺陋!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起怀各,我...
    開封第一講書人閱讀 37,872評論 0 268
  • 序言:老撾萬榮一對情侶失蹤倔韭,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后瓢对,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體寿酌,經(jīng)...
    沈念sama閱讀 44,318評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,645評論 2 327
  • 正文 我和宋清朗相戀三年硕蛹,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了醇疼。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,777評論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡法焰,死狀恐怖秧荆,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情埃仪,我是刑警寧澤乙濒,帶...
    沈念sama閱讀 34,470評論 4 333
  • 正文 年R本政府宣布,位于F島的核電站卵蛉,受9級(jí)特大地震影響颁股,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜傻丝,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,126評論 3 317
  • 文/蒙蒙 一甘有、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧葡缰,春花似錦亏掀、人聲如沸忱反。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,861評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽温算。三九已至,卻和暖如春该互,著一層夾襖步出監(jiān)牢的瞬間米者,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,095評論 1 267
  • 我被黑心中介騙來泰國打工宇智, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留蔓搞,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,589評論 2 362
  • 正文 我出身青樓随橘,卻偏偏與公主長得像喂分,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子机蔗,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,687評論 2 351

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