網(wǎng)易友品組件化演進

項目背景

主站業(yè)務(wù)經(jīng)歷了長期的迭代維護辕录,業(yè)務(wù)的增長同時帶來每個版本業(yè)務(wù)量繁重碉考,迭代周期很快轿曙。同時團隊也在不斷的擴張,對應(yīng)拆分了組內(nèi)不同的業(yè)務(wù)線對接不同業(yè)務(wù)線的需求,最初的Android客戶端單一的設(shè)計架構(gòu)已經(jīng)逐漸不滿足快速的業(yè)務(wù)開發(fā)需求。歷經(jīng)組內(nèi)討論開始對項目整理進行組件化的遷移统诺,通過組件化的方式滿足不同業(yè)務(wù)線業(yè)務(wù)開發(fā)的穩(wěn)定性皮璧,是迭代開發(fā)更靈活,組內(nèi)協(xié)作開發(fā)效率得到提升遥巴。同時又有新的項目立項需要投入開發(fā)千康,一方面可以通過新項目實踐和推進組件化的遷移,另一方面也可以通過組件化拆分后的技術(shù)組件復(fù)用來更快的搭建和開發(fā)新的項目铲掐。

組件化的準(zhǔn)備

技術(shù)準(zhǔn)備

  1. 主站最初的app項目只有一個模塊拾弃,業(yè)務(wù)耦合嚴(yán)重,技術(shù)組件很難復(fù)用摆霉,所以我們采取的第一步是拆分部分基礎(chǔ)組件下沉為一個Base庫豪椿,盡量去解耦業(yè)務(wù)提取基礎(chǔ)技術(shù)組件達(dá)到多業(yè)務(wù)模塊的復(fù)用奔坟,也是為了支持新項目和主站項目多個app的技術(shù)支持。

  2. 考慮組件化后的業(yè)務(wù)相對隔離搭盾,但是客戶端組件間需要建立訪問咳秉,所以需要組件間通信的介入。我們采取的方式是路由鸯隅、服務(wù)和全局通知澜建。

  3. 搭建路由庫支持,目的是解決業(yè)務(wù)組件物理隔離后的UI跳轉(zhuǎn)和訪問蝌以,通過維護路由表的方式尋址到需要訪問的業(yè)務(wù)組件UI炕舵。我們采取的是技術(shù)實現(xiàn)是通過注解給對應(yīng)的業(yè)務(wù)UI比如LoginActivity上用注解申明對應(yīng)的路由地址,在公共依賴的接口處公開維護這個路由地址常量跟畅,暴露給其他業(yè)務(wù)組件通過方位該地址來跳轉(zhuǎn)到對應(yīng)的業(yè)務(wù)組件UI咽筋。


@Router(RouterPath.LOGIN_PAGE)

public class LoginActivity extends BaseCompatActivity

public class RouterPath {

    /**

    * 登錄

    */

    public static final String LOGIN_PAGE = "/native/xxx-login\\.html";

    /**

    * 搜索key

    */

    public static final String SEARCH_KEY = "/native/xxx-search-key\\.html";

    ...

}

對應(yīng)Act綁定上路由地址后,需要對路由的地址進行統(tǒng)一的收集管理徊件。同時也為了支持某些服務(wù)動態(tài)下發(fā)的地址晤硕,策略是優(yōu)先在本地的路由表進行匹配,如果查詢到了該地址有對應(yīng)的Native界面優(yōu)先跳轉(zhuǎn)到Native的界面庇忌,未匹配到則跳轉(zhuǎn)到由webView容器承載的網(wǎng)頁舞箍。目前我們采取的方式是通過APT自動生成對應(yīng)路由注解后的activity的收集類。


//自動生成的類皆疹,命名規(guī)則是RouterGenerator+業(yè)務(wù)組件模塊名稱

//RouterGenerator_login.class

public class RouterGenerator_login implements RouterProvider {

    public RouterGenerator_login() {

    }

    public void loadRouter(Map<String, Route> routerMap, Map<String, Route> pageNameRouterMap) {

        String keyLoginActivity = "((https|http|domain|native)://(\\w+\\.)?domain\\.com/native/domain-login\\.html)|(" + RouteBuilder.generateUriFromClazz(LoginActivity.class) + ")";

        routerMap.put(keyLoginActivity, RouteBuilder.build(keyLoginActivity, 0, false, (String[])null, LoginActivity.class));

    }

}

然后再通過ASM的方式在編譯期對所有加載到工程里面的模塊組件通過特定的規(guī)則進行上面路由輔助類的收集疏橄。


//收集路由地址

[

    'scanInterface'        : 'com.kaola.annotation.provider.RouterProvider',

    'scanSuperClasses'    : [],

    'codeInsertToClassName': 'com.kaola.core.center.router.RouterMap',

    //未指定codeInsertToMethodName,默認(rèn)插入到static塊中略就,故此處register必須為static方法

    'registerMethodName'  : 'register',

    'include'              : ['com/kaola/annotation/provider/result/.*'

]

//根據(jù)工程依賴的所有組件模塊收集所有實現(xiàn)RouterProvider的輔助類捎迫。

//然后插入到RouterMap的靜態(tài)代碼塊中,默認(rèn)調(diào)用無參構(gòu)造表牢。

//遍歷執(zhí)行RouterMap中的靜態(tài)方法register窄绒,添加所有路由地址信息到全局路由表sRouterMap中。

public class RouterMap {

    private static Map<String, Route> sRouterMap = new ConcurrentHashMap<>();

    private static Map<String, Route> sPageRouterMap = new ConcurrentHashMap<>();

    private static void register(RouterProvider routerProvider) {

        routerProvider.loadRouter(sRouterMap, sPageRouterMap);

    }

}

具體實現(xiàn)不再此展開了崔兴,此方式的好處就是可以根據(jù)需求加載需要的業(yè)務(wù)組件并且實現(xiàn)自動注冊和收集路由到路由表彰导。如果覺得獨立開發(fā)路由庫的成本較高,也可以采取業(yè)界主流的一些路由庫比如ARouter等敲茄,基本類似位谋。

  1. 關(guān)于組件間服務(wù)通信的方式,目前采取的是暴露對應(yīng)的服務(wù)接口供各個業(yè)務(wù)組件方調(diào)用堰燎。每個業(yè)務(wù)組件都會申明需要對外暴露提供的方法掏父,并在自己的業(yè)務(wù)組件模塊內(nèi)實現(xiàn)這些具體被調(diào)用的方法。對外接口庫根據(jù)模塊劃分秆剪,可以申明和維護通信間的一些數(shù)據(jù)類型赊淑,比如公開的數(shù)據(jù)model和對應(yīng)需要訪問的一些路由地址等爵政。為了便于服務(wù)的動態(tài)收集,這些服務(wù)接口可以統(tǒng)一的繼承某個規(guī)則接口陶缺,然后采取上述路由的方式钾挟,對所有實現(xiàn)了該規(guī)則接口的服務(wù)接口統(tǒng)一的收集管理。

facade/pay/

            model/PayModel.class

            IPayService.class

    //IService.class组哩,統(tǒng)一對繼承IService的服務(wù)接口的具體實現(xiàn)類進行收集

    interface PayService : IService {

        fun startH5PaySercive(context: Context)

    }

pay_module/

    PayServiceImpl.class

    class PayServiceImpl : PayService {

        override fun startH5PaySercive(context: Context) {

            //...

        }

    }

剩下一些特點場景的業(yè)務(wù)等龙,比如:登錄成功后需要全局通知刷新多個UI某個業(yè)務(wù)狀態(tài)的時候,目前采取EventBus的方式進行訂閱通知伶贰。

  1. 在組件base庫一定下沉和組件間通信方式的確立蛛砰,開始對組件的具體的拆分粒度進行劃分。大致劃分為業(yè)務(wù)組件和技術(shù)組件兩部分黍衙。

組件化的拆分流程

拆分前的考慮

考慮新的項目投入的人力資源有限泥畅,并且需要快速的開發(fā)上線,同時業(yè)務(wù)也有重合的場景琅翻。所以當(dāng)時采取的開發(fā)策略是將主站未組件化的代碼完全拷貝一份到新項目位仁,并在此的基礎(chǔ)上進行改造。改造的原則必須遵循2個應(yīng)用共建同一套BaseLib方椎,但是由于主站的BaseLib里面會耦合一些自身的業(yè)務(wù)組件聂抢,同時避免對BaseLib的修改影響到主站的業(yè)務(wù)開發(fā)而增加不必要的工作量。當(dāng)時采取的策略是通過增加一層業(yè)務(wù)基礎(chǔ)組件庫來做新項目組件化拆分的緩沖層BaseCompatLib棠众。

拆分過程

WX20190313-113131@2x.png

拆分過程中有很多業(yè)務(wù)組件共用的情況琳疏,結(jié)合當(dāng)時的開發(fā)周期可以適當(dāng)?shù)娜ソ怦畈糠謽I(yè)務(wù)組件重新劃分到對應(yīng)拆分后的業(yè)務(wù)模塊中。如果時間有限闸拿,可以先挪到BaseCompatLib這個緩沖成暫時共用待后續(xù)再拆空盼,從而避免對2個項目共用的Base庫頻繁修改帶來的負(fù)擔(dān)。

初期的業(yè)務(wù)模塊獨立編譯的配置方式新荤,僅供參考:


//gradle.properties中申明編譯配置是否是獨立編譯

# Module Build

isModuleInjectBuild=true

//moduleLibrary的build.gradle中申明編譯方式

if (isModuleInjectBuild.toBoolean()) {

    apply from: '../build_module.gradle'

} else {

    apply from: '../build_app.gradle'

}

//新建一個appbuild文件揽趾,用來支業(yè)務(wù)組件以app方式編譯時所需的配置

//示例:

java/appbuild/

            BuildInfo.class //獨立配置

            HomeServiceImpl.class //改寫應(yīng)用啟動跳轉(zhuǎn)的UI

            App.class //獨立編譯時的application,用于初始化配置

android {

    //配置源碼路徑

    sourceSets {

        main {

            jniLibs.srcDirs = ['src/main/jnilibs']

            //如果是整體編譯苛骨,可以移除獨立編譯所需的額外代碼

            if (isModuleInjectBuild.toBoolean()) {

                java {

                    exclude 'appbuild/**'

                }

            }

        }

    }

}

遇到的問題

拆分后的獨立模塊由于一些基礎(chǔ)服務(wù)的初始化仍停留在app殼工程篱瞎,一些sdk或者初始化服務(wù)沒有統(tǒng)一的管理。優(yōu)先級混亂并且耦合大量的業(yè)務(wù)邏輯智袭,導(dǎo)致業(yè)務(wù)模塊拆分后無法獨立運行奔缠,缺失對應(yīng)組件所需服務(wù)的初始化步驟。開始改造初始化的業(yè)務(wù)吼野,原理同自動收集一致。


image2018-11-16 16_23_42.png

interface IInitializer {

    fun loadInQueue(queue: PriorityQueue<InitialTask>) //收集需要的服務(wù)進隊列

    fun init(processName: String) //對應(yīng)初始化服務(wù)的實現(xiàn)

}

class InitialManager {

    companion object {

        private val mInitializerQueue = PriorityQueue<InitialTask>() //服務(wù)隊列

        private var mCurProcessName: String = "" //當(dāng)前啟動的進程

        //應(yīng)用初始化時的調(diào)用的入口函數(shù)

        @JvmStatic

        fun initial(curProcessName: String) {

            mCurProcessName = curProcessName

            initialInProcess()

        }

        @JvmStatic

        fun initialInProcess() {

            loop@ while (mInitializerQueue.isNotEmpty()) {  //搜索接入了多少三方sdk功能两波,總?cè)蝿?wù)隊列

                val initialTask = mInitializerQueue.poll() //按優(yōu)先級取

                //根據(jù)是否擁有權(quán)限去加載普通任務(wù)

                //特殊不需要檢查權(quán)限的任務(wù)瞳步,包括:Config和Permission初始化本身的任務(wù)闷哆。

                //目前這些優(yōu)先級必須高于普通任務(wù),否則會被提前打斷单起,等到權(quán)限獲取后才會執(zhí)行抱怔。

                when {

                    PermissionUtils.isNecessaryPermissionGranted() || initialTask.isNoNeedPermissionCheck() -> {

                        executeTask(initialTask)

                    }

                    else -> {

                        //一旦被權(quán)限檢查打斷不能執(zhí)行,取出的任務(wù)重新放回隊列嘀倒。跳出任務(wù)隊列屈留,等待權(quán)限獲取后的再次執(zhí)行。

                        mInitializerQueue.add(initialTask)

                        break@loop

                    }

                }

            }

        }

        /**

        * 執(zhí)行任務(wù)测蘑,匹配對應(yīng)進程灌危,對應(yīng)進程啟動對應(yīng)需要初始化的任務(wù),沿用主站的邏輯

        */

        private fun executeTask(initialTask: InitialTask) {

            initialTask.processName.forEach {

                //當(dāng)前進程和服務(wù)需要初始化的進程相匹配或者是全進程需要就加載

                if (it == mCurProcessName || it == InitialTask.INITIAL_ALL_PROCESS) {

                    Log.d("InitialManager", "initial - process:$mCurProcessName & initialTask:${initialTask.initialName}")

                    initialTask.initializer.init(mCurProcessName)

                    return@forEach

                }

            }

        }

        @JvmStatic

        fun register(initializer: IInitializer) {

            initializer.loadInQueue(mInitializerQueue)

        }

    }

//示例服務(wù)   

class QiyuSdkInitial : IInitializer {

    override fun loadInQueue(queue: PriorityQueue<InitialTask>) {

        //主進程需要

        val initialTask = InitialTask(

                processName = mutableListOf(ProcessConst.MAIN_PROCESS, ProcessConst.NIM_PROCESS),

                initialName = this::class.java.simpleName,

                initializer = this

        )

        queue.add(initialTask)

    }

    override fun init(processName: String) {

        try {

            QiyuSdk.initUnicorn(AppDelegate.sApplication)

        } catch (e: Throwable) {

            e.printStackTrace()

        }

    }

}

徹底組件化

架構(gòu)圖

友品組件化.png

組件庫的獨立發(fā)布和維護

原有拆分的本地組件徹底分離出去碳胳,采取獨立發(fā)布和維護的方式迭代更新勇蝙。

  1. 新建git倉庫和本地組件項目,然后以module的方式將原有項目中的業(yè)務(wù)module導(dǎo)入到本地新建的項目中挨约。推送該項目到git的獨立倉庫味混。(目前未采取git subModule的 方式管理,但大致差不多)

  2. 新建的本地項目中再新建一個對應(yīng)的接口工程用于對外暴露模塊中的業(yè)務(wù)訪問诫惭。


project: component-login

                        /app            //殼工程

                        /login          //登錄模塊module

                        /login-facade  //登錄模塊接口module

  1. 添加打包aar發(fā)布到maven倉庫的腳本用來獨立發(fā)布login和login-facade模塊翁锡。

  2. 遵循對應(yīng)的發(fā)布規(guī)范,不同項目的app殼工程根據(jù)自身的業(yè)務(wù)需求進行對應(yīng)的組件依賴夕土,版本開發(fā)階段可采取snapshot的進行依賴馆衔。不同的業(yè)務(wù)組件也可以通過依賴其他不同的業(yè)務(wù)組件接口達(dá)到訪問的目的。(如需實際運行隘弊,不光需要再接入接口庫還需要依賴對應(yīng)的組件工程)

  3. 目前友品采取的是jenkins的打包發(fā)布方式哈踱,僅供參考。

本地開發(fā)調(diào)試模式

在組件開發(fā)過程中梨熙,單純的依靠遠(yuǎn)程方式依賴开镣,對開發(fā)階段的頻繁修改不友好。所以我們采取依賴覆蓋的方式咽扇,讓原有的依賴在編譯過程中替換掉遠(yuǎn)程的版本改用本地的版本進行引用邪财。


// 自定義const.gradle環(huán)境聲明

def version = '1.5.11'

ext.sdk = [

        YpBase        : { "com.kaola:ypbase:${version}" }

]

//app build.gradle

dependencies {

    api gradle.sdk.YpBase(this)

}

//setting.gradle

gradle.ext {

    sdk = sdk

}

//本地依賴時需要修改為本地的路徑

def YpBase_PATH = "localpath/base"

def YpBase_as_aar = []

def YpBase_as_sources = [

        ['YpBase', ":base", ['type': 'project', 'path': "${YpBase_PATH}/base"]],

]

def overrideList = YpBase_as_aar

// *核心* 打開注釋使用源碼引入YpBase

overrideList = YpBase_as_sources

def overrideLibrary(Map define, String whichLibrary, String name, Map prjType) {

    def overrideType = prjType.get("type")

    if (overrideType == 'module') {

        include(name)

        define.put(whichLibrary, {

            it.project(name)

        })

    } else if (overrideType == 'project') {

        include(name)

        project(name).projectDir = new File(prjType.get('path'))

        define.put(whichLibrary, {

            it.project(name)

        })

    } else if (overrideType == 'aar') {

        define.put(whichLibrary, { prjType.get('path') })

    } else {

        ; // ignore

    }

}

for (int i = 0; i < overrideList.size(); i++) {

    def override = overrideList[i]

    println 'override: ' + override[0]

    overrideLibrary(sdk, override[0], override[1], override[2])

}

通過以上的方式讓Base的依賴從遠(yuǎn)程替換為本地module的形式。開發(fā)階段就可以通過AS的refactor進行代碼的優(yōu)化和重構(gòu)质欲,對本地Base修改后到Base的git分支進行對應(yīng)的提交或MR合回主分支然后走規(guī)范的發(fā)布打包流程树埠。

組件版本依賴管理

組件項目中會有對Base或者接口庫的引用,對于Base我們可以選擇compileOnly的方式嘶伟,也可以選擇直接依賴的方式怎憋。在集成到項目中后依賴會遵循gradle的依賴傳遞原則。特別注意:

  1. 避免環(huán)形依賴的產(chǎn)生。比如:facade -> base绊袋, base -> facade毕匀。遇到這種情況需要拆分所需依賴到另外一層。

  2. 在遠(yuǎn)程依賴替換為本地依賴做開發(fā)修改時可能會遇到遠(yuǎn)程依賴和本地依賴的沖突癌别。比如:app -> login -> com.xxx:base; app -> home -> /localpath/base皂岔。 此時可以采取下面的方式進行依賴優(yōu)先選擇本地的方式排除掉其他組件中的遠(yuǎn)程依賴。


//setting.gradle

def base_exist = false

for (int i = 0; i < overrideList.size(); i++) {

    def override = overrideList[i]

    println 'override: ' + override[0]

    if (override[0] == 'YpBase') {

        base_exist = true

    }

    overrideLibrary(sdk, override[0], override[1], override[2])

}

gradle.ext {

    kulabase_exist = base_exist

}

//app.gradle

if (gradle.kulabase_exist) {

    println 'kulabase_exist exist, exclude all aar dependences'

    android {

        configurations {

            all*.exclude group: 'com.xxx', module: 'base'

        }

    }

}

后續(xù)

到此為止基本上組件化就可以持續(xù)穩(wěn)定的開發(fā)和維護了展姐,組件化后也給團隊的開發(fā)效率帶來一定的提升躁垛,代碼也可以在一定可控的范圍內(nèi)穩(wěn)定的維護。并且在各自維護的組件中圾笨,大家也可以根據(jù)各自需求選擇合適自己業(yè)務(wù)的開發(fā)框架比如:mvp教馆、LiveData、Rx等或者嘗試使用新語言Kotlin去編寫墅拭。解決業(yè)務(wù)耦合帶來的負(fù)擔(dān)同時也使各個組件達(dá)到了較高的可復(fù)用性活玲,靈活的支持不同的應(yīng)用項目,達(dá)到可插拔的方式集成開發(fā)谍婉。后續(xù)項目也會做一些優(yōu)化舒憾,針對版本依賴的管理和簡化組件編譯和發(fā)布集成的流程來提高協(xié)作開發(fā)的效率。

ASM自動收集參考:https://github.com/luckybilly/AutoRegister

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末穗熬,一起剝皮案震驚了整個濱河市镀迂,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌唤蔗,老刑警劉巖探遵,帶你破解...
    沈念sama閱讀 217,657評論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異妓柜,居然都是意外死亡箱季,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,889評論 3 394
  • 文/潘曉璐 我一進店門棍掐,熙熙樓的掌柜王于貴愁眉苦臉地迎上來藏雏,“玉大人,你說我怎么就攤上這事作煌【蚺梗” “怎么了?”我有些...
    開封第一講書人閱讀 164,057評論 0 354
  • 文/不壞的土叔 我叫張陵粟誓,是天一觀的道長奏寨。 經(jīng)常有香客問我,道長鹰服,這世上最難降的妖魔是什么病瞳? 我笑而不...
    開封第一講書人閱讀 58,509評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上仍源,老公的妹妹穿的比我還像新娘心褐。我一直安慰自己舔涎,他們只是感情好笼踩,可當(dāng)我...
    茶點故事閱讀 67,562評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著亡嫌,像睡著了一般嚎于。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上挟冠,一...
    開封第一講書人閱讀 51,443評論 1 302
  • 那天于购,我揣著相機與錄音,去河邊找鬼知染。 笑死肋僧,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的控淡。 我是一名探鬼主播嫌吠,決...
    沈念sama閱讀 40,251評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼掺炭!你這毒婦竟也來了辫诅?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,129評論 0 276
  • 序言:老撾萬榮一對情侶失蹤涧狮,失蹤者是張志新(化名)和其女友劉穎炕矮,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體者冤,經(jīng)...
    沈念sama閱讀 45,561評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡肤视,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,779評論 3 335
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了涉枫。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片邢滑。...
    茶點故事閱讀 39,902評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖拜银,靈堂內(nèi)的尸體忽然破棺而出殊鞭,到底是詐尸還是另有隱情,我是刑警寧澤尼桶,帶...
    沈念sama閱讀 35,621評論 5 345
  • 正文 年R本政府宣布操灿,位于F島的核電站,受9級特大地震影響泵督,放射性物質(zhì)發(fā)生泄漏趾盐。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,220評論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望救鲤。 院中可真熱鬧久窟,春花似錦、人聲如沸本缠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,838評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽丹锹。三九已至稀颁,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間楣黍,已是汗流浹背匾灶。 一陣腳步聲響...
    開封第一講書人閱讀 32,971評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留租漂,地道東北人阶女。 一個月前我還...
    沈念sama閱讀 48,025評論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像哩治,于是被迫代替她去往敵國和親秃踩。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,843評論 2 354