Clean Architecture 學習之單元測試

為什么要做單元測試

學習過或者了解軟件工程的人一定對這個東西不陌生,很多人也知道這個東西很重要,但是總是以各種借口來推脫,這其中就包括我。大學我學習的并不是軟件工程,所以對什么黑盒測試、白盒測試、灰盒測試只是聽說過,并沒有什么具體感覺。前段時間正好看了bob大叔的的《代碼整潔之道》和 另外一本經典《重構:改善既有代碼的設計》,兩位作者都對單元測試以及TDD(測試驅動開發)推崇備至,我看完之后也是激動不已,正好手頭有個新項目。于是當即決定將這種開發模式引入進來。
題外話說了這么多,我們就先來看看寫單元測試的一些好處吧!

1. 開發新功能時,避免遺漏功能點

我們在開發的過程中,常常出現因為新功能太多而遺忘的情況。等我們提測之后,看到滿屏的bug,心里一定是崩潰的。但是如果我們先寫好單元測試,或者說打好樁,然后根據功能點一個一個的開發,既避免了遺忘,又能測試我們的代碼,一舉兩得啊!

2. 重構代碼時,避免影響其他功能

重構的時候,我們最擔心的就是影響其他模塊或功能。但是如果我們提前寫好了單元測試,我們就能很輕易的就發現我們在重構的過程中出現的side effect

3. 提高我們的編程能力

單元測試寫多了之后,我們很容易就能在編碼的過程中注意到各種邊界條件,從而寫出健壯性更好的程序

4. 提高我們的效率

很多人看到這點的時候會覺得奇怪。雖然看起來我們花了很多時間在編寫單元測試上,但是一旦單元測試寫好了之后,基本上就是一勞永逸的,難道你不覺得讓電腦自動去測試比我們挨個挨個去點我們的app效率更高嗎?更何況,假設有人過來接手我們寫的項目的時候,他們只需要打開單元測試就知道我們這個項目,這個模塊做了些什么事情,就能更快速的上手了。

看了這么多單元測試的好處之后,是不是有些躍躍欲試了?先不著急,我們先來看看Clean Architecture 的結構,分析分析Clean Architecture 的特點再對癥下藥。
Clean Architecture 中,它的業務邏輯代碼放在了domain 層,是純 java 代碼,data 層用到了一些 android 平臺的東西,包括網絡訪問、數據存儲等。UI 層又劃分成了P(presentor) 和 V(view),presentor 是純 java代碼,view 部分才是跟 android 緊密相關的。所以我們需要兩類工具:測試純java代碼的和測試android相關的。

Java 代碼的單元測試

純 Java 代碼測試框架很多,最出名的應該是 Junit 和 TestNG 了。Android Studio 默認使用的是 JUnit 4 ,大概是因為JUnit 4的使用人群最多吧。我們接下來就介紹JUnit 4的功能和特點。

使用JUnit進行單元測試

最開始的JUnit 是由Kent Beck 和Eric Gamma 在飛機上寫出來的(膜拜ing)。因為Android Studio已經將其內置了,所以我們就不需要再額外引入了。只需要在我們想要添加單元測試的module 的build.gradle 中添加下面這句話:

testCompile "junit:junit:4.12" 

一般來說,單元測試分為三步:

  1. setup:即new 出待測試的類,設置一些前提條件

  2. 執行動作:即調用被測類的被測方法,并獲取返回結果

  3. 驗證結果:驗證獲取的結果跟預期的結果是一樣的

一個簡單的例子

假設我們的代碼中有一個購物車類和一個商品類,每當一個商品加入到購物車之后,購物車會計算商品總價。

public class Goods {
    private String name;
    private long id;
    private long price;
    private int quantities;
    
    public Goods(long id){
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public long getId() {
        return id;
    }

    public long getPrice() {
        return price;
    }

    public void setPrice(long price) {
        this.price = price;
    }

    public int getQuantities() {
        return quantities;
    }

    public void setQuantities(int quantities) {
        this.quantities = quantities;
    }

    public long getTotalPrice() {
        return price * quantities;
    }
}

生成測試代碼

我們在Goods.java 的源碼中任意位置單擊右鍵,按照下圖所示,Android Studio 就會引導我們生成測試類。

Testing library 一欄選擇JUnit4。class name 就用默認的,否則就無法從被測試類快速跳轉到測試類中了。如果我們要做一些初始化工作,我們就需要勾選setUp。然后在下面方法列表中選擇我們想要測試哪些方法。一般來說,我們不會去測試代碼中簡單的 setter 和 getter , 除非里面有很復雜的邏輯代碼。所以,我們這里只測試 getTotalPrice 這個方法;


最后在生成的代碼中添加以下測試代碼:

public class GoodsTest {
    
    private Goods goods;
    
    @Before
    public void setUp() throws Exception {
        goods = new Goods(1);  

    }

    @Test
    public void testGetTotalPrice() throws Exception {
        
        goods.setPrice(112);
        goods.setQuantities(10);

        Assert.assertEquals(112*12,goods.getTotalPrice());
    }
}  

其中 setup 方法對應的前面所說的 setup 部分,testGetTotalPrice 方法的前兩句對應前面所說的執行動作,而最后一句 Assert 就是第三部分驗證結果。

我們可以單擊方法名左側的綠色三角按鈕來測試單個方法,或者快捷鍵 Ctrl+Shift+F10 來運行整個測試類。測試結果會在下方顯示:


測試通過
測試失敗

測試失敗,運行結果會告訴我們在哪一行出錯了以及期望的結果是什么,而實際結果又是什么。

使用Mock框架

在寫單元測試的過程中,一個很普遍的問題是,要測試的類會有很多依賴,這些依賴的類/對象/資源又會有別的依賴,從而形成一個大的依賴樹,要在單元測試的環境中完整地構建這樣的依賴,是一件很困難的事情。所幸我們有一個應對這個問題的解決方案:mock。我們使用 mock 框架可以模擬任何類,構建一個假對象,而不需要實際運行這個類,我們可以定義這些假對象上的行為,提供給被測試對象使用。被測試對象像使用真的對象一樣使用它們。用這種方式,我們可以把測試的目標限定于被測試對象本身,就如同在被測試對象周圍做了一個劃斷,形成了一個盡量小的被測試目標。

引入Mock框架

Mock的框架有很多,最為知名的一個是Mockito,這是一個開源項目,使用廣泛。同樣,在build.gradle中添加以下語句:

testCompile "org.mockito:mockito-core:1.9.5"

我們先來看一個官方的示例:

import org.mockito.Mockito;

// 創建mock對象
List mockedList = Mockito.mock(List.class);

// 設置mock對象的行為 - 當調用其get方法獲取第0個元素時,返回"one"
Mockito.when(mockedList.get(0)).thenReturn("one");

// 使用mock對象 - 會返回前面設置好的值"one",即便列表實際上是空的
String str = mockedList.get(0);

Assert.assertTrue("one".equals(str));
Assert.assertTrue(mockedList.size() == 0);

// 驗證mock對象的get方法被調用過,而且調用時傳的參數是0
Mockito.verify(mockedList).get(0); 

代碼中的注釋描述了代碼的邏輯:先創建mock對象mockedList,然后設置mock對象上的方法get,指定當get方法被調用,并且參數為0的時候,返回”one”;然后,調用被測試方法(被測試方法會調用mock對象的get方法);
上面這個示例揭示了最簡單的使用情況,當我最開始看到這個示例的時候,對最后一句很是困惑,覺得這句完全沒有必要,直到我在項目中寫下面這些代碼:
被測試類

public class UserLoginImpl extends UseCase implements UserLogin {

    UserRepository userRepository;

    private String name;
    private String pwd;

    @Inject
    public UserLoginImpl(UserRepository userRepository,ThreadExecutor threadExecutor, PostExecutionThread postExecutionThread){
        super(threadExecutor,postExecutionThread);
        this.userRepository = userRepository;
    }

    @Override
    protected Observable buildUseCaseObservable() {
        return userRepository.userLogin(name,pwd);
    }

    @Override
    public UserLogin setAccount(String name) {
        this.name = name;
        return this;
    }

    @Override
    public UserLogin setPwd(String pwd) {
        this.pwd = MD5.parseStrToMd5U32(pwd);
        return this;
    }
}

測試類

public class UserLoginTest {
    private UserLoginImpl userLogin;

    @Mock private ThreadExecutor mockThreadExecutor;
    @Mock private PostExecutionThread mockPostExecutionThread;
    @Mock private UserRepository mockUserRepository;

    private static final String FAKE_USER_ACCOUNT = "13478969876";
    private static final String FAKE_USER_PWD = "13478969876";

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        userLogin = new UserLoginImpl(mockUserRepository,mockThreadExecutor,mockPostExecutionThread);
        userLogin.setAccount(FAKE_USER_ACCOUNT);
        userLogin.setPwd(FAKE_USER_PWD);
    }

    @Test
    public void testBuildUseCaseObservable() throws Exception {
        userLogin.buildUseCaseObservable();
        verify(mockUserRepository).userLogin(FAKE_USER_ACCOUNT,FAKE_USER_PWD);
        verifyNoMoreInteractions(mockUserRepository);//驗證mockUserRepository是否還有其他地方被調用過
        verifyZeroInteractions(mockPostExecutionThread); //驗證mockPostExecutionThread是否被調用過
        verifyZeroInteractions(mockThreadExecutor);//驗證mockThreadExecutor是否被調用過
    }
}
Paste_Image.png

竟然失敗了!它告訴我它期望的密碼是13478969876,而實際調用的確是一長串的字符。研究了很久,終于發現了原因:在設置密碼的時候,我算出密碼的MD5值后,將MD5值存成為密碼,執行 userLogin 方法的時候調用的是加密后的字符串,而我在 verify 函數中使用的是沒有加密的密碼,兩者不一致,因而編譯器報錯。
使用 Mock 框架,不但能讓其返回任何我們任何我們需要的數據,而且還能驗證我們是否正確調用了,這么強大又好用的功能,還不趕快用起來!

Android 代碼的單元測試

android 的單元測試比純java代碼的復雜多了。純 java 代碼我們直接在PC上就能運行了,因為它只依賴JVM。但是android 代碼要跑起來當然需要android的運行環境了,比如TextView、Toast等,雖然它也是一個變種的JVM。如果我們的代碼還需要在模擬器或真機上才能測試,那效率可想而知有多慢了。有沒有能在PC的JVM上就能運行測試代碼的工具呢?當然有,接下來我們就介紹我們的主角——Robolectric。

使用Robolectric進行單元測試

Robolectric 是一個開源框架,它實現了一套在 JVM 上能運行的 Android 開發環境。它實現一套 Shadow* 的東西,比如ShadowTextView , ShadowToast等控件。顧名思義,影子對象(Shadow Object)并不是真正的對象,它只是真實對象的一個影子。真實對象做了任何動作,產生了任何效果,我們通過影子對象就能知道,并能夠通過影子對象就能知道真實對象的結果。

Robolectric環境搭建

把下面這段話加入到您的build.gradle中來就可以將Robolectric 引入你的項目中了:

testCompile "org.robolectric:robolectric:3.0"

但是如果您的項目使用了MultiDex,那您就需要使用最新的3.2了。
按照前文介紹過的方法,我們生成對應的測試代碼,然后通過注解配置TestRunner

@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class)
public class LoginActivityTest {
}

Activity 的測試

創建activity 實例
@Test
public void testActivity() {
     SampleActivity sampleActivity = Robolectric.setupActivity(SampleActivity.class);
     assertNotNull(sampleActivity);
     assertEquals(sampleActivity.getTitle(), "SimpleActivity");
 }
activity 生命周期
@Test
public void testLifecycle() {
     ActivityController<SampleActivity> activityController = Robolectric.buildActivity(SampleActivity.class).create().start();
     Activity activity = activityController.get();
     TextView textview = (TextView) activity.findViewById(R.id.tv_lifecycle_value);
     assertEquals("onCreate",textview.getText().toString());
     activityController.resume();
     assertEquals("onResume", textview.getText().toString());
     activityController.destroy();
     assertEquals("onDestroy", textview.getText().toString());
 }
跳轉
@Test
public void testStartActivity() {
     forwardBtn.performClick();
     Intent expectedIntent = new Intent(sampleActivity, LoginActivity.class);
     Intent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();
     assertEquals(expectedIntent, actualIntent);
 }
UI組件狀態
@Test
public void testViewState(){
     CheckBox checkBox = (CheckBox) sampleActivity.findViewById(R.id.checkbox);
     Button inverseBtn = (Button) sampleActivity.findViewById(R.id.btn_inverse);
     assertTrue(inverseBtn.isEnabled());

     checkBox.setChecked(true);
     inverseBtn.performClick();
     assertTrue(!checkBox.isChecked());
     inverseBtn.performClick();
     assertTrue(checkBox.isChecked());
 }
Dialog
@Test
public void testDialog(){
     //點擊按鈕,出現對話框
     dialogBtn.performClick();
     AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
     assertNotNull(latestAlertDialog);
 }
Toast
@Test
public void testToast(){
     toastBtn.performClick();
     assertEquals(ShadowToast.getTextOfLatestToast(),"we love UT");
 }
Fragment

如果使用support的Fragment,需添加以下依賴

testCompile "org.robolectric:shadows-support-v4:3.0"

shadow-support包提供了將Fragment主動添加到Activity中的方法:SupportFragmentTestUtil.startFragment(),簡易的測試代碼如下

@Test
public void testFragment(){
 SampleFragment sampleFragment = new SampleFragment();
 //此api可以主動添加Fragment到Activity中,因此會觸發Fragment的onCreateView()
 SupportFragmentTestUtil.startFragment(sampleFragment);
 assertNotNull(sampleFragment.getView());
}
訪問資源
@Test
public void testResources() {
     Application application = RuntimeEnvironment.application;
     String appName = application.getString(R.string.app_name);
     String activityTitle = application.getString(R.string.title_activity_simple);
     assertEquals("LoveUT", appName);
     assertEquals("SimpleActivity",activityTitle);
 }

Service的測試

Service的測試類似于BroadcastReceiver,以IntentService為例,可以直接觸發onHandleIntent()方法,用來驗證Service啟動后的邏輯是否正確。

public class SampleIntentService extends IntentService {
    public SampleIntentService() {
        super("SampleIntentService");
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        SharedPreferences.Editor editor = getApplicationContext().getSharedPreferences(
                "example", Context.MODE_PRIVATE).edit();
        editor.putString("SAMPLE_DATA", "sample data");
        editor.apply();
    }
}

以上代碼的單元測試用例:

@Test
public void addsDataToSharedPreference() {
        Application application = RuntimeEnvironment.application;
        RoboSharedPreferences preferences = (RoboSharedPreferences) application
                .getSharedPreferences("example", Context.MODE_PRIVATE);

        SampleIntentService registrationService = new SampleIntentService();
        registrationService.onHandleIntent(new Intent());

        assertEquals(preferences.getString("SAMPLE_DATA", ""), "sample data");
    }

BroadcastReceiver 的測試

首先看下廣播接收者的代碼

public class MyReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        SharedPreferences.Editor editor = context.getSharedPreferences(
                "account", Context.MODE_PRIVATE).edit();
        String name = intent.getStringExtra("EXTRA_USERNAME");
        editor.putString("USERNAME", name);
        editor.apply();
    }
}

廣播的測試點可以包含兩個方面,一是應用程序是否注冊了該廣播,二是廣播接受者的處理邏輯是否正確,關于邏輯是否正確,可以直接人為的觸發onReceive()方法,驗證執行后所影響到的數據。

@Test
public void testBoradcast(){
        ShadowApplication shadowApplication = ShadowApplication.getInstance();

        String action = "com.geniusmart.loveut.login";
        Intent intent = new Intent(action);
        intent.putExtra("EXTRA_USERNAME", "geniusmart");

        //測試是否注冊廣播接收者
        assertTrue(shadowApplication.hasReceiverForIntent(intent));

        //以下測試廣播接受者的處理邏輯是否正確
        MyReceiver myReceiver = new MyReceiver();
        myReceiver.onReceive(RuntimeEnvironment.application,intent);
        SharedPreferences preferences = shadowApplication.getSharedPreferences("account", Context.MODE_PRIVATE);
        assertEquals( "geniusmart",preferences.getString("USERNAME", ""));
    }

自定義控件的測試

Robolectric 定義了很多影子類,它擴展或繼承了Android OS對應的類。當創建一個Android 類,Robolectric 就會查找對應的影子類,如果找到了,Robolectric 就會創建一個影子對象與之對應。當調用Android 類的方法的時候,Robolectric 就會確保對應的方法被調用了。如果 Robolectric 的影子類不能滿足您的要求,你還可以安裝一定的要求編寫自己的影子類。最常見的應該就是自定義控件了。
我們封裝了一個toast,它的代碼如下所示:

public class FBToast  {
    private static Toast toast = null;
    private static TextView view = null;

    public static void showShortToast(Context context, String msg) {
        showToast(context,msg,Toast.LENGTH_SHORT);
    }


    public static void showToast(Context context, String msg, int duration) {
        if (toast == null) {
            toast = new Toast(context);
            view = (TextView) LayoutInflater.from(context)
                    .inflate(R.layout.publish_toast, null);
            view.setText(msg);
            view.setPadding(30, 80, 30, 80);
            toast.setView(view);
            toast.setDuration(duration);
            toast.setGravity(Gravity.CENTER, 0, 0);
        } else {
            view.setText(msg);
            toast.setDuration(duration);
        }

        toast.show();
    }

    public static void cancel(){
        if(toast != null) {
            toast.cancel();
        }
    }
}

代碼中使用如下:

    @Override
    public void showErrorMessage(String message) {
        FBToast.showShortToast(this,message);
    }

如果我們不擴展它的影子類,那我們是無法測試程序是否正確調用了Toast的相關代碼。
FBToast的shadow 對象:

@Implements(FBToast.class)
public class ShadowFBToast extends ShadowToast{

    @Implementation
    public static void showShortToast(Context context, String msg){
        showToast(context,msg,Toast.LENGTH_SHORT);
    }
    @Implementation
    public static void showToast(Context context, String msg, int duration){
        ShadowToast.makeText(context,msg,duration).show();
    }

    public static String getTextOfLatestToast(){
        return ShadowToast.getTextOfLatestToast();
    }

}

自定義shadow對象要求必須在類定義中加上@Implements(AndroidClassName.class)注解,并在你在代碼中使用的公共方法上也加上@Implementation注解。最后,你需要在你的測試代碼的config注解加上這個自定義Shadow 對象。

@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class,shadows = {ShadowFBToast.class})
public class LoginActivityTest {
    ....
}

RxJava 單元測試的注意事項

RxJava 給我們帶來了非常大的便利,它簡化了邏輯,避免了"嵌套地獄",邏輯清晰簡單,如水銀泄地。但是它也給我們進行單元測試帶來了一些麻煩。

匿名類的問題

由于Mockito 不支持匿名類,所以我們在使用RxJava的時候要特別注意。相信很多人跟我一樣,Subscriber 都是像下面這樣寫的。

userLogin.setAccount(account)
        .setPwd(pwd)
        .execute(new BaseSubscriber<Boolean>() {
            @Override
            public void onCompleted() {
                
            }

            @Override
            public void onError(Throwable e) {

            }

            @Override
            public void onNext(Boolean o) {
                view.renderSuccessView();
            }
        });
    @Test
    public void testSubmit() throws Exception {
        TestSubscriber<Boolean> testSubscriber = new TestSubscriber<>();
        loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);
        Mockito.verify(userLogin).setAccount(FAKE_ACCOUNT);
        Mockito.verify(userLogin).setPwd(FAKE_ACCOUNT);
        Mockito.verify(userLogin).execute(testSubscriber);
    }

控制臺就會報下面這個錯誤:


為了解決這個問題,你就必須將匿名類變成內部類。

    @Override
    public void submit(String account, String pwd) {
        if(validate(account,pwd)){
            userLogin.setAccount(account)
                    .setPwd(pwd)
                    .execute(new UserLoginSubscriber());
        }
    }
    public final class UserLoginSubscriber extends BaseSubscriber<Boolean> {
        @Override
        public void onCompleted() {
        }
        @Override
        public void onError(Throwable e) {
        }
        @Override
        public void onNext(Boolean aBoolean) {
            view.renderSuccessView();
        }
    }

異步回調的測試

還是以前一節的代碼為例,如果我們想測試UserLoginSubscriber這個類里面的三個方法,我們該怎么做呢?
Mockito 為我們提供了兩個解決方案:

1. doAnswer

我們可以使用都doAnswer為一個函數進行打樁以測試異步函數。當被測試的方法被調用時我們生成了一個通用的anwser,這個回調會被執行。UserLoginSubscriber 是回調函數所在的類,LoginPresenter是我們要測試的類,UserLogin是我們mock的對象,UserLogin執行了UserLoginSubscriber的回調方法。具體看代碼:

@Mock
UserLogin userLogin;
@Mock
LoginPresenter.View mockView;
private LoginPresenter loginPresenter;

@Test
public void testSubmit() throws Exception {
    Mockito.doAnswer(new Answer() {
        @Override
        public Object answer(InvocationOnMock invocation) throws Throwable {
            ((UserLoginSubscriber)invocation.getArguments()[0]).onNext(true);
            return null;
        }
    }).when(userLogin).execute(Mockito.any(UserLoginSubscriber.class));
    loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);
    Mockito.verify(userLogin,Mockito.times(1)).execute(Mockito.any(BaseSubscriber.class));
    Mockito.verify(mockView).renderSuccessView();
}
2. ArgumentCaptor

在這里我們的UserLoginSubscriber是異步的: 我們通過ArgumentCaptor捕獲傳遞到UserLogin對象的UserLoginSubscriber回調。

@Mock
UserLogin userLogin;
@Mock
LoginPresenter.View mockView;
@Captor
ArgumentCaptor<LoginPresenterImpl.UserLoginSubscriber> argumentCaptor;

private LoginPresenter loginPresenter;

@Test
public void testSubmit() throws Exception {

    loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);

    Mockito.verify(userLogin).execute(argumentCaptor.capture());
    argumentCaptor.getValue().onNext(true);
    Mockito.verify(mockView).renderSuccessView();
}

doAnswer 和 ArgumentCaptor 都是值得大書特書的東西,這里我們就只簡單介紹到這里。如果有機會,會單獨寫一篇來介紹這兩個東西。

RxJava 的Subscriber 的測試

RxJava 由于使用鏈式調用,而且通常最后subscribe方法是沒有返回值的,所以我們沒有辦法去像常規單元測試一樣對其進行測試。所以,我們不得不動用一些非常規武器---TestSubscriber。哈哈,其實不是,這是官方提供的,使用方法如下:

@Test
public void testGetGoodsCategories() throws Exception {
    TestSubscriber<List<GoodsCategory>> testSubscriber = new TestSubscriber<>();
    GoodsRepository.getGoodsCategories(FAKE_CATEGORY_ID).subscribe(testSubscriber);
    testSubscriber.assertNoErrors();

}

@Test
public void testInvalidCategoryId(){
    TestSubscriber<List<GoodsCategory>> testSubscriber = new TestSubscriber<>();
    GoodsRepository.getGoodsCategories(INVALID_CATEGORY_ID).subscribe(testSubscriber);
    testSubscriber.assertError(IllegalArgumentException.class);
}

非常簡單,我們通過檢查TestSubscriber 的回調結果,就能知道我們的程序是否按照我們預想的運行。TestSubscriber 還有其他方法,諸如assertValue(),assertCompleted()等等。總之,RxJava 為我們提供了豐富的工具來進行測試。

結束語

本文只是最基本的單元測試指南,許多高級使用技巧我們并沒有涉及到,比如JUnit 的Rule。 這需要我們在日后的工作中一點點去學習和積累。其實,關于Dagger2 的,還有一個據說很神奇的開源庫DaggerMock,但由于我沒有試驗成功,所以這里就不介紹了,等到哪天我學會如何使用之后,再把這塊內容補上。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容