国产露脸精品国产沙发|极品妇被弄得99久九精品亚洲|亚洲va成精品在线播放人|日韩精品久久久免费观看

    
    

        <delect id="w59je"></delect>

            當(dāng)前位置:首頁 > 乒乓球資訊 > 正文內(nèi)容

            場景面試題:怎么寫一個 Android 日志庫?

            杏彩體育2年前 (2022-12-05)乒乓球資訊142

            原標(biāo)題:場景面試題:怎么寫一個 Android 日志庫?

            安卓進(jìn)階漲薪訓(xùn)練營,讓一部分人先進(jìn)大廠

            大家好,我是皇叔,最近開了一個安卓進(jìn)階漲薪訓(xùn)練營,可以幫助大家突破技術(shù)&職場瓶頸,從而度過難關(guān),進(jìn)入心儀的公司。

            詳情見文章: 沒錯!皇叔開了個訓(xùn)練營

            在面試中,這類題目稱為場景題,即就一個實際業(yè)務(wù)場景給出解決方案,難度較高,若無與之相關(guān)的實戰(zhàn)經(jīng)驗,非??简炁R場應(yīng)變及綜合運用儲備知識的能力。這篇就來分析下“寫一個 Log 需要考慮些哪些方面?”

            先拋一個磚

            簡單是任何一個庫設(shè)計都要考慮的首要問題。

            接口設(shè)計

            如果庫接口設(shè)計不合理,造成理解、接入、使用的高復(fù)雜度。那。。。這個庫就沒人用唄~

            先來看這樣一個設(shè)計:

            abstractclassULogBase: IULog {

            privatevallogger bylazy {

            ULogCore(initConfig)

            }

            overridefunv(tag: String, message: String) {

            logger.printLog(Log.VERBOSE, tag, message)

            }

            overridefunv(tag: String, message: String, throwable: Throwable) {

            logger.printLog(Log.VERBOSE, tag, message)

            }

            overridefuni(tag: String, message: String) {

            logger.printLog(Log.INFO, tag, message)

            }

            overridefuni(tag: String, message: String, throwable: Throwable) {

            logger.printLog(Log.INFO, tag, message, throwable)

            }

            overridefund(tag: String, message: String) {

            logger.printLog(Log.DEBUG, tag, message)

            }

            overridefund(tag: String, message: String, throwable: Throwable) {

            logger.printLog(Log.DEBUG, tag, message, throwable)

            }

            overridefunw(tag: String, message: String) {

            logger.printLog(Log.WARN, tag, message)

            }

            overridefunw(tag: String, message: String, throwable: Throwable) {

            logger.printLog(Log.WARN, tag, message, throwable)

            }

            overridefune(tag: String, message: String) {

            logger.printLog(Log.ERROR, tag, message)

            }

            overridefune(tag: String, message: String, throwable: Throwable) {

            logger.printLog(Log.ERROR, tag, message,throwable)

            }

            overridefuna(tag: String, message: String, tagReport: String) {

            logger.printLog(Log.ASSERT, tag, message, tagReport = tagReport)

            }

            overridefuna(tag: String, message: String, throwable: Throwable, tagReport: String) {

            logger.printLog(Log.ASSERT, tag, message, throwable, tagReport = tagReport)

            }

            abstractfuninitConfig: ULogConfig

            }

            這是 Log 庫對外公布的接口。它尊重了 Android 端打 Log 的習(xí)俗。畢竟 android.util.Log 已經(jīng)習(xí)以為常。使用新庫時,平滑過渡,不會有陌生感。

            但這個接口設(shè)計有優(yōu)化的地方。

            overridefunv(tag: String, message: String) {

            logger.printLog(Log.VERBOSE, tag, message)

            }

            overridefunv(tag: String, message: String, throwable: Throwable) {

            logger.printLog(Log.VERBOSE, tag, message, throwable)

            }

            同級別的 Log 輸出,聲明了2個方法。在 Java 中不得不這樣做,因參數(shù)不同而進(jìn)行 方法重載。

            運用 Kotlin 的語法特性 參數(shù)默認(rèn)值可空類型,就能降低接口復(fù)雜度:

            // 聲明參數(shù) throwable 是可空類型,并默認(rèn)賦予空值

            overridefunv(tag: String, message: String, throwable: Throwable? = null) {

            logger.printLog(Log.VERBOSE, tag, message, throwable)

            }

            把2個接口變?yōu)?個接口,而調(diào)用效果保持不變:

            ULog.v( "test", "log")

            ULog.v( "test", "log",Exception( "error"))

            當(dāng)調(diào)用第一條語句時,Kotlin 默認(rèn)給 throwable 參數(shù)傳遞 null 值。

            接入難度

            這個接口設(shè)計,第二個尷尬點是 ULogBase 是抽象的,業(yè)務(wù)層不得不實例化才能使用。。。

            這無疑增加了使用負(fù)擔(dān)。而這樣做的目的僅僅是為了“配置”,即根據(jù)不同的業(yè)務(wù)場景傳入不同的參數(shù):

            objectNimLogger : ULogBase {

            overridefuninitConfig: ULogConfig {

            returnULogConfig.Builder(requireNotNull(NimConfig.context)) // 和 context 耦合

            .module( "nim")

            .debug(NimConfig.isDebug) // 和編譯類型耦合

            .build

            }

            }

            業(yè)務(wù)端必須重寫 initConfig 方法生成一個 ULogConfig 對象,用來表示業(yè)務(wù)配置。

            看看業(yè)務(wù)配置包括哪些:

            classULogConfigprivateconstructor(builder: Builder) {

            valmodule = builder.mModule // 模塊名

            valisDebug = builder.mIsDebug // 編譯類型

            valcontext = builder.context // 上下文

            vallogModulePath = builder.mLogModulePath // 路徑名

            vallogRootPath = builder.mLogRootPath // 根路徑名

            valenableToFile = builder.enableToFile // 是否寫文件

            valisValidPath : Boolean

            get{

            return!TextUtils.isEmpty(logModulePath) && !TextUtils.isEmpty(logRootPath)

            }

            classBuilder( valcontext: Context) {

            lateinitvarmModule: String

            privateset

            varmIsDebug: Boolean= true

            privateset

            varenableToFile: Boolean= true

            privateset

            varmLogModulePath: String = ""

            privateset

            varmLogRootPath: String = ""

            privateset

            funmodule(name: String) : Builder {

            mModule = name

            returnthis

            }

            fundebug(debug: Boolean) : Builder {

            mIsDebug = debug

            returnthis

            }

            funenableToFile(enable : Boolean) : Builder {

            enableToFile = enable

            returnthis

            }

            funbuild: ULogConfig {

            if(mModule == null) {

            throwIllegalArgumentException( "Be sure to set the module name")

            }

            if(TextUtils.isEmpty(mLogModulePath)) {

            mLogModulePath = getLogPath(mModule)

            }

            if(TextUtils.isEmpty(mLogRootPath)) {

            mLogRootPath = getLogPath

            }

            returnULogConfig( this)

            }

            privatefungetLogPath(module : String= "") : String {

            returnif(mIsDebug) {

            File(context.filesDir, "uLog/debug/ $module" ).absolutePath

            } else{

            File(context.filesDir, "uLog/release/ $module" ).absolutePath

            }

            }

            }

            }

            業(yè)務(wù)配置信息包括,模塊名、編譯類型、上下文、路徑名、是否寫文件。

            其中前 4 個參數(shù)是為了給不同模塊的日志生成不同的日志路徑。這個設(shè)計,見仁見智~

            優(yōu)點是信息降噪,只關(guān)注想關(guān)注模塊的日志。缺點是,大部分業(yè)務(wù)模塊都和多個底層功能模塊耦合,大部分問題是綜合性問題,需要關(guān)注整個鏈路上所有模塊的日志。

            我偏好的策略是,將所有日志打入一個文件,通過 tag 來區(qū)分模塊,如果只想關(guān)注某個模塊的日志,sublime 可以方便地選中包含指定 tag 的所有行,一個復(fù)制粘貼就完成了信息降噪。畢竟想分開是輕而易舉的事情,但是想合并就很難了~

            復(fù)雜度(建造者模式)

            Kotlin 相較于 Java 的最大優(yōu)勢就是降低復(fù)雜度。在庫接口設(shè)計及內(nèi)部實現(xiàn)時就要充分發(fā)揮 Kotlin 的優(yōu)勢,比如 Kotlin 的世界里已不需要 Builder 模式了。

            Builder 模式有如下優(yōu)勢:

            為參數(shù)標(biāo)注語義:在Builder模式中,每個屬性的賦值都是一個函數(shù),函數(shù)名標(biāo)注了屬性語義。

            可選參數(shù)&分批賦值:Builder模式中,除了必選參數(shù),其他參數(shù)是可選的,可分批賦值。而直接使用構(gòu)造函數(shù)必須一下子為所有參數(shù)賦值。

            增加參數(shù)約束條件:可以在參數(shù)不符合要求時,拋出異常。

            但 Builder 模式也有代價,新增了一個中間類 Builder 。

            使用 Kotlin 的命名參數(shù)+參數(shù)默認(rèn)值+require語法,在沒有任何副作用的情況下就能實現(xiàn) Builder 模式:

            classPerson(

            valname: String,

            //為以下可選參數(shù)設(shè)置默認(rèn)值

            valgender: Int= 1,

            valage: Int= 0,

            valheight: Int= 0,

            valweight: Int= 0

            )

            //使用命名參數(shù)構(gòu)建 Person 實例

            valp = Person(name = “taylor”,gender = 1,weight = 43)

            命名參數(shù)為每個實參賦予了語義,而且不需要按構(gòu)造方法中聲明的順序來賦值,可以跳著來。

            如果想增加參數(shù)約束條件可以調(diào)用 require 方法:

            dataclassPerson(

            // 這個是必選參數(shù)

            valname: String,

            valgender: Int= 1,

            valage: Int= 0,

            valheight: Int= 0,

            valweight: Int= 0

            ){

            //在構(gòu)造函數(shù)被調(diào)用的時候執(zhí)行參數(shù)合法檢查

            init{

            require(name.isNotEmpty){”name cant be empty“}

            }

            }

            此時如果像下面這樣構(gòu)造 Person,則會拋出異常:

            valp = Person(name= "",gender = 1)

            java.lang.IllegalArgumentException: name cant be empty

            本來在 build 方法中執(zhí)行的額外初始化邏輯也可以全部寫在 init 代碼塊中。

            最后這個庫,在具體打印日志時的操作也及其復(fù)雜(不知道你能不能一下子看明白這 log 是怎么打的?):

            internalclassULogCore( privatevalconfig: ULogConfig) {

            companionobject{

            constvalDELETE_DAY_NUMBER = 7

            constvalTAG_LOG = "ULogCore"

            constvalSUFFIX = ".java"

            constvalTAG_PARAM = "param"

            constvalKEY_ASSET_MSG = "key_asset_message"

            constvalSTACK_TRACK_INDEX = 7

            valtag_log_type = arrayOf( "VERBOSE", "DEBUG", "INFO", "WARN", "ERROR", "ASSERT")

            varinitOperation = AtomicBoolean( false)

            varlogDateFormat = SimpleDateFormat( "yyyy-MM-dd", Locale.CHINA)

            }

            funprintLog(

            type: Int,

            tagText: String,

            message: String,

            throwable: Throwable? = null,

            tagReport: String? = ""

            ) {

            valstackTrace = Thread.currentThread.stackTrace

            CoroutineScope(Dispatchers.Default).launch {

            valfinalMessage = processMessage(type, message, stackTrace)

            valtagFinal = if(TextUtils.isEmpty(tagText)) TAG_LOG elsetagText

            if(config.isDebug) {

            when(type) {

            Log.VERBOSE -> Log.v(tagFinal, finalMessage, throwable)

            Log.DEBUG -> Log.d(tagFinal, finalMessage, throwable)

            Log.INFO -> Log.i(tagFinal, finalMessage, throwable)

            Log.WARN -> Log.w(tagFinal, finalMessage, throwable)

            Log.ERROR -> Log.e(tagFinal, finalMessage, throwable)

            Log.ASSERT -> Log.e(tagFinal, finalMessage, throwable)

            else-> {

            }

            }

            }

            vallogDir = File(config.logModulePath)

            if(!logDir.exists || logDir.isFile) {

            logDir.mkdirs

            }

            if(!initOperation.getAndSet( true)) {

            valfilterDates = ArrayList<String>.apply {

            for(i in0..DELETE_DAY_NUMBER) {

            valcalendar = Calendar.getInstance

            calendar.add(Calendar.DAY_OF_MONTH, -i)

            valdate = logDateFormat.format(calendar.time)

            Log.i(TAG_LOG, date)

            add(date)

            }

            }

            config.logRootPath.takeIf { !TextUtils.isEmpty(it) }?.run {

            vallist = ArrayList<File>

            File( this).listFiles?.forEach { file ->

            Log.i(TAG_LOG, "module $this" )

            file.listFiles?.filter { child ->

            !child.isMatchDateFile(filterDates)

            }?.apply {

            list.addAll( this)

            }

            }

            list

            }?.run {

            forEach {

            Log.i(TAG_LOG, "delete file ${it.name}" )

            it.delete

            }

            }

            if(!ULogFwHThread.isAlive) {

            ULogFwHThread.start

            }

            }

            if(config.enableToFile) {

            if(config.isValidPath) {

            varformatReportTag = ""

            if(type == Log.ASSERT && tagReport != null&& tagReport.isNotEmpty) {

            varassetMsgSet = MMKV.defaultMMKV.getStringSet(KEY_ASSET_MSG, HashSet)

            valrawMsg = message+tagReport

            Log.i(TAG_LOG, "asset md5 raw message $rawMsg" )

            valkey = ULogUtils.getMD5(rawMsg, false)

            Log.i(TAG_LOG, "assetMsgSet is $assetMsgSet, asset message key is $key" )

            formatReportTag = "[# $tagReport#]"

            if(assetMsgSet?.contains(key) == true) {

            Log.i(TAG_LOG, "This log is asset mode and has save to file once, so ignore it.")

            } else{

            assetMsgSet?.add(key)

            MMKV.defaultMMKV.putStringSet(KEY_ASSET_MSG, assetMsgSet)

            EventBus.getDefault.post(

            ULogTagEvent(

            tagReport,

            logDateFormat.format(Date(System.currentTimeMillis))

            )

            )

            }

            }

            ULogFwHThread.addEntry(

            ULogEntry(

            config,

            type,

            "[ $tagFinal] $formatReportTag: $message" ,

            throwable

            )

            )

            } else{

            Log.i(TAG_LOG, "printLog invalid log file path logRootPath : "+

            " ${config.logRootPath}logModulePath : ${config.logModulePath}" )

            }

            } else{

            Log.i(TAG_LOG, "enableToFile is closed")

            }

            }

            }

            privatefunprocessMessage(

            type: Int,

            msg: String,

            stackTraceElement: Array< StackTraceElement>

            ) : String {

            funtypeTag(logType: Int) : String? {

            returnif(logType >= Log.VERBOSE && logType <= Log.ASSERT) tag_log_type[logType - 2] else"LOG"

            }

            valtargetElement = stackTraceElement[STACK_TRACK_INDEX]

            varfileName = targetElement.fileName

            if(TextUtils.isEmpty(fileName)) {

            varclassName = targetElement.className

            valclassNameInfo = className.split( "\.".toRegex).toTypedArray

            if(classNameInfo.isNotEmpty) {

            className = classNameInfo[classNameInfo.size - 1]

            }

            if(className.contains( "$")) {

            className = className.split( "\$".toRegex).toTypedArray[ 0]

            }

            fileName = className + SUFFIX

            }

            valmethodName = targetElement.methodName

            varlineNumber = targetElement.lineNumber

            if(lineNumber < 0) {

            lineNumber = 0

            }

            valheadString = String.format( "[ (%s:%s) => %s ]", fileName, lineNumber, methodName)

            valthreadInfo = String.format( "thread - {{ %s }}", Thread.currentThread.name)

            valsb = StringBuilder(headString).apply {

            append( "\n╔═════════════════════════════════════════════════════════════════════════════════════════")

            append( "\n║ ").append(threadInfo)

            append( "\n╟┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈")

            append( "\n║ [").append(typeTag(type)).append( "] - ").append(msg)

            append( "\n╚═════════════════════════════════════════════════════════════════════════════════════════")

            }

            returnsb.toString

            }

            }

            printLog 是每次打日志都會調(diào)用的方法,在這個方法里面做了很多很多事情~,包括美化日志、向 Logcat 輸出日志、創(chuàng)建日志文件夾、刪除過期日志、寫日志到文件、將日志上傳云。

            先不說違反了單一職責(zé)原則造成的高復(fù)雜度,高理解成本,同時也不能滿足下面這些需求:

            自定義我的日志美化樣式。(畢竟我們不一樣,有不同的審美)

            在不改庫的前提下,更換寫文件方式(比如更高性能的I/O庫)。

            在不改庫的前提下,更換上傳云邏輯。(從阿里云換成亞馬遜云)

            在不改庫的前提下,增加本地日志加密。

            在不改庫的前提下,修改日志文件的根目錄。

            這些動態(tài)的配置效果,應(yīng)該是一個庫具備的彈性。如果動不動就要改庫,它還配叫庫?

            如果一定要給 “一個庫的基本修養(yǎng)”提兩條綱領(lǐng)的話,我愿意把 “簡單”“彈性”放在首位。

            高可擴(kuò)展

            看完剛才的磚之后,我再拋一個磚。

            objectEasyLog {

            privateconstvalVERBOSE = 2

            privateconstvalDEBUG = 3

            privateconstvalINFO = 4

            privateconstvalWARN = 5

            privateconstvalERROR = 6

            privateconstvalASSERT = 7

            // 攔截器列表

            privatevallogInterceptors = mutableListOf<LogInterceptor>

            fund(message: String, tag: String= "", varargargs: Any) {

            log(DEBUG, message, tag, *args)

            }

            fune(message: String, tag: String= "", varargargs: Any, throwable: Throwable? = null) {

            log(ERROR, message, tag, *args, throwable = throwable)

            }

            funw(message: String, tag: String= "", varargargs: Any) {

            log(WARN, message, tag, *args)

            }

            funi(message: String, tag: String= "", varargargs: Any) {

            log(INFO, message, tag, *args)

            }

            funv(message: String, tag: String= "", varargargs: Any) {

            log(VERBOSE, message, tag, *args)

            }

            funwtf(message: String, tag: String= "", varargargs: Any) {

            log(ASSERT, message, tag, *args)

            }

            // 注入攔截器

            funaddInterceptor(interceptor: LogInterceptor) {

            logInterceptors.add(interceptor)

            }

            // 從頭部注入攔截器

            funaddFirstInterceptor(interceptor: LogInterceptor) {

            logInterceptors.add( 0, interceptor)

            }

            }

            日志庫對上層的接口封裝在一個單例 EasyLog 里。

            日志庫提供了和 android.util.Log 幾乎一樣的打印接口,但增加了一個可變參數(shù) args ,這是為了方便地為字串的通配符賦值。

            假責(zé)任鏈模式

            日志庫還提供了一個新接口 addInterceptor ,用于動態(tài)地注入日志攔截器:

            interfaceLogInterceptor{

            // 進(jìn)行日志

            funlog(priority: Int, tag: String, log: String)

            // 是否允許進(jìn)行日志

            funenable: Boolean

            }

            日志攔截器是一個接口,定義了兩個抽象的行,為分別是進(jìn)行日志和是否允許日志。

            所有的日志接口都將打印日志委托給了 log 方法:

            objectEasyLog {

            @Synchronized

            privatefunlog(

            priority: Int,

            message: String,

            tag: String,

            varargargs: Any,

            throwable: Throwable? = null

            ) {

            // 為日志通配符賦值

            varlogMessage = message.format(*args)

            // 如果有異常,則讀取異常堆棧拼接在日志字串后面

            if(throwable != null) {

            logMessage += getStackTraceString(throwable)

            }

            // 遍歷日志攔截器,將日志打印分發(fā)給所有攔截器

            logInterceptors.forEach { interceptor ->

            if(interceptor.enable) interceptor.log(priority, tag, logMessage)

            }

            }

            // 對 String.format 的封裝,以求簡潔

            funString. format( varargargs: Any) =

            if(args.isNullOrEmpty) thiselseString.format( this, *args)

            // 讀取堆棧

            privatefungetStackTraceString(tr: Throwable?) : String {

            if(tr == null) {

            return""

            }

            vart = tr

            while(t != null) {

            if(t isUnknownHostException) {

            return""

            }

            t = t.cause

            }

            valsw = StringWriter

            valpw = PrintWriter(sw)

            tr.printStackTrace(pw)

            pw.flush

            returnsw.toString

            }

            }

            這里用了同步方法,為了防止多線程調(diào)用時日志亂序。

            這里還運用了 責(zé)任鏈模式(假的),使得 EasyLog 和日志處理的具體邏輯解耦,它只是持有一組日志攔截器,當(dāng)有日志請求時,就分發(fā)給所有的攔截器。

            這樣做的好處就是,可以在業(yè)務(wù)層動態(tài)地為日志組件提供新的功能。

            當(dāng)然日志庫得提供一些基本的攔截器,比如將日志輸出到 Logcat:

            openclassLogcatInterceptor: LogInterceptor {

            overridefunlog(priority: Int, tag: String, log: String) {

            Log.println(priority, tag, log)

            }

            overridefunenable: Boolean{

            returntrue

            }

            }

            Log.println(priority, tag, log) 是 android.util.Log 提供的,按優(yōu)先級將日志輸出到 Logcat 的方法。使用這個方法可以降低復(fù)雜度,因為不用寫類似下面的代碼:

            when(priority){

            VERBOSE -> Log.v(...)

            ERROR -> Log.e(...)

            }

            之所以要將 LogcatInterceptor 聲明為 open,是因為業(yè)務(wù)層有動態(tài)重寫 enable 方法的需求:

            EasyLog.addInterceptor( object: LogcatInterceptor {

            overridefunenable: Boolean{

            returnBuildConfig.DEBUG

            }

            })

            這樣就把日志輸出到 Logcat 的開關(guān) 和 build type 聯(lián)系起來了,就不需要將 build type 作為 EasyLog 的一個配置字段了。

            除了輸出到 Logcat,另一個基本需求就是日志文件化,新建一個攔截器:

            classFileWriterLogInterceptor

            privateconstructor( privatevardir: String) : LogInterceptor {

            privatevalhandlerThread = HandlerThread( "log_to_file_thread")

            privatevalhandler: Handler

            privatevaldispatcher: CoroutineDispatcher

            // 帶參單例

            companionobject{

            @Volatile

            privatevarINSTANCE: FileWriterLogInterceptor? = null

            fungetInstance(dir: String) : FileWriterLogInterceptor =

            INSTANCE ?: synchronized( this) {

            INSTANCE ?: FileWriterLogInterceptor(dir).apply { INSTANCE = this}

            }

            }

            init{

            // 啟動日志線程

            handlerThread.start

            handler = Handler(handlerThread.looper)

            // 將 handler 轉(zhuǎn)換成 Dispatcher

            dispatcher = handler.asCoroutineDispatcher( "log_to_file_dispatcher")

            }

            overridefunlog(priority: Int, tag: String, log: String) {

            // 啟動協(xié)程串行地將日志寫入文件

            if(!handlerThread.isAlive) handlerThread.start

            GlobalScope.launch(dispatcher) {

            FileWriter(getFileName, true).use {

            it.append( "[ $tag] $log" )

            it.append( "\n")

            it.flush

            }

            }

            }

            overridefunenable: Boolean{

            returntrue

            }

            privatefungetToday: String =

            SimpleDateFormat( "yyyy-MM-dd").format(Calendar.getInstance.time)

            privatefungetFileName= " $dir${File.separator}${getToday}.log"

            }

            日志寫文件的思路是: “異步串行地將字串通過流輸出到文件中”

            異步化是為了不阻塞主線程,串行化是為了保證日志順序。HandlerThread 就很好地滿足了異步化串行的要求。

            為了簡化“將日志作為消息發(fā)送到異步線程中”這段代碼,使用了協(xié)程,這樣代碼就轉(zhuǎn)變成:每次日志請求到來時,啟動協(xié)程,在其中完成創(chuàng)建流、輸出到流、關(guān)閉流。隱藏了收發(fā)消息的復(fù)雜度。

            日志攔截器被設(shè)計為單例,目的是讓 App 內(nèi)存中只存在一個寫日志的線程。

            日志文件的路徑由構(gòu)造方法傳入,這樣就避免了日志攔截器和 Context 的耦合。

            use 是 Closeable 的擴(kuò)展方法,它隱藏了流操作的 try-catch,降低了復(fù)雜度

            然后業(yè)務(wù)層就可以像這樣動態(tài)地為日志組件添加寫文件功能:

            classMyApplication: Application{

            overridefunonCreate{

            super.onCreate

            // 日志文件路徑

            valdir = this.filesDir.absolutePath

            // 構(gòu)造日志攔截器單例

            valinterceptor = FileWriterLogInterceptor.getInstance(dir)

            // 注入日志攔截器單例

            EasyLog.addInterceptor(interceptor)

            }

            }

            真責(zé)任鏈模式

            還有一個對日志庫的基本訴求就是“美化日志”。

            還是重新定義一個攔截器:

            // 調(diào)用堆棧攔截器

            classCallStackLogInterceptor: LogInterceptor {

            companionobject{

            privateconstvalHEADER =

            "┌──────────────────────────────────────────────────────────────────────────────────────────────────────"

            privateconstvalFOOTER =

            "└──────────────────────────────────────────────────────────────────────────────────────────────────────"

            privateconstvalLEFT_BORDER =

            // 用于過濾日志調(diào)用棧(將EasyLog中的類過濾)

            privatevalblackList = listOf(

            CallStackLogInterceptor:: class. java. name,

            EasyLog::class.java.name

            )

            }

            overridefunlog(priority: Int, tag: String, log: String) {

            // 打印頭部

            Log.println(priority, tag, HEADER)

            // 打印日志

            Log.println(priority, tag, " $LEFT_BORDER$log" )

            // 打印堆棧信息

            getCallStack(blackList).forEach {

            valcallStack = StringBuilder

            .append(LEFT_BORDER)

            .append( "\t ${it}" ).toString

            Log.println(priority, tag, callStack)

            }

            // 打印尾部

            Log.println(priority, tag, FOOTER)

            }

            overridefunenable: Boolean{

            returntrue

            }

            }

            對于業(yè)務(wù)層的一條日志,調(diào)用堆棧攔截器輸出了好幾條日志,依次是頭部、日志、堆棧、尾部。

            為了降低復(fù)雜度,把獲取調(diào)用棧的邏輯單獨抽象為一個方法:

            fungetCallStack(blackList: List< String>) : List<String> {

            returnThread.currentThread

            .stackTrace.drop( 3) // 獲取調(diào)用堆棧,過濾上面的3個,因為它們就是這里看到3個方法

            .filter { it.className ! inblackList } // 過濾黑名單

            .map { " ${it.className}. ${it.methodName}( ${it.fileName}: ${it.lineNumber})" }

            }

            按照上面 map 中那樣的格式,在 Logcat 中就能有點擊跳轉(zhuǎn)效果。

            然后把調(diào)用堆棧攔截器插入到所有攔截器的頭部:

            EasyLog.addFirstInterceptor(CallStackLogInterceptor)

            打印效果是這樣的:

            看上去還不錯~,但當(dāng)我打開日志文件后,卻只有 DemoActivity.onCreate 這一行日志,并沒有堆棧信息。。。

            原因就在于我用了一個 假的責(zé)任鏈模式!!

            責(zé)任鏈模式就好比“老師發(fā)考卷”:

            真責(zé)任鏈?zhǔn)沁@樣發(fā)考卷的:老師將考卷遞給排頭同學(xué),排頭同學(xué)再傳遞給第二個同學(xué),如此往復(fù),直到考卷最終遞到我的手里。

            假責(zé)任鏈?zhǔn)沁@樣發(fā)考卷的:老師站講臺不動,叫到誰的名字,誰就走上去拿自己的考卷。

            學(xué)生時代當(dāng)然希望老師用假責(zé)任鏈模式發(fā)考卷,因為若用真責(zé)任鏈,前排的每一個同學(xué)都可以看到我的分?jǐn)?shù),還能在我的考卷上亂涂亂畫。

            但在打日志這個場景中,用假責(zé)任鏈模式的后果就是, 后續(xù)攔截器拿不到前序攔截器的處理結(jié)果

            其實它根本稱不上責(zé)任鏈,因為就不存在“鏈”,至多是一個“策略模式的遍歷”

            所以只能用真責(zé)任鏈重構(gòu),為了向后傳遞攔截器的處理結(jié)果, 攔截器就得持有其后續(xù)攔截器

            interfaceLogInterceptor{

            // 后續(xù)攔截器

            varnextInterceptor:LogInterceptor?

            funlog(priority: Int, tag: String, log: String)

            funenable: Boolean

            }

            然后在具體的攔截器實例中實現(xiàn)這個抽象屬性:

            openclassLogcatInterceptor: LogInterceptor {

            overridevarnextInterceptor: LogInterceptor? = null

            get= field

            set(value) {

            field = value

            }

            overridefunlog(priority: Int, tag: String, log: String) {

            if(enable) {

            Log.println(priority, tag, log)

            }

            // 將log請求傳遞給下一個攔截器

            nextInterceptor?.log(priority, tag, log)

            }

            overridefunenable: Boolean{

            returntrue

            }

            }

            因為所有的攔截器都通過鏈的方式連接,所以 EasyLog 就不需要再持有一組攔截器,而只需要持有頭攔截器就好了:

            objectEasyLog {

            // 頭攔截器

            privatevallogInterceptor : LogInterceptor? = null

            // 注入攔截器

            funsetInterceptor(interceptor: LogInterceptor) {

            logInterceptor = interceptor

            }

            }

            注入攔截器的代碼也需要做相應(yīng)的變化:

            classMyApplication: Application{

            overridefunonCreate{

            super.onCreate

            // 構(gòu)建所有攔截器

            valdir = this.filesDir.absolutePath

            valfileInterceptor = FileWriterLogInterceptor.getInstance(dir)

            vallogcatInterceptor = LogcatInterceptor

            valcallStackLogInterceptor = CallStackLogInterceptor

            // 安排攔截器鏈接順序

            callStackLogInterceptor.nextInterceptor = logcatInterceptor

            logcatInterceptor.nextInterceptor = fileInterceptor

            // 將頭部攔截器注入

            EasyLog.setInterceptor(callStackLogInterceptor)

            }

            }

            這個設(shè)計能滿足功能要求,但是對于接入方來說,復(fù)雜度有點高,因為不得不手動安排攔截器的順序,而且如果想改動攔截器的順序也非常麻煩。

            我想到了 OkHttp,它也采用了真攔截器模式,但并不需要手動安排攔截器的順序。它是怎么做到的?由于篇幅原因,源碼分析不展開了,感興趣的同學(xué)可以點開 okhttp3.internal.http.RealInterceptorChain 。

            下面運用這個思想,重構(gòu)一下 EasyLog。

            首先要新建一條鏈:

            classChain(

            // 持有一組攔截器

            privatevalinterceptors: List<LogInterceptor>,

            // 當(dāng)前攔截器索引

            privatevalindex: Int= 0

            ) {

            // 將日志請求在鏈上傳遞

            funproceed(priority: Int, tag: String, log: String) {

            // 用一條新的鏈包裹鏈上的下一個攔截器

            valnext = Chain(interceptors, index + 1)

            // 執(zhí)行鏈上當(dāng)前的攔截器

            valinterceptor = interceptors.getOrNull(index)

            // 執(zhí)行當(dāng)前攔截器邏輯,并傳入新建的鏈

            interceptor?.log(priority, tag, log, next)

            }

            }

            鏈持有一組攔截器和索引,其中索引表示當(dāng)前需要執(zhí)行的是哪個攔截器。

            鏈包含一個 procee 方法,它是讓日志請求在鏈上傳遞起來的關(guān)鍵。每次執(zhí)行該方法都會新建一個鏈條并將索引+1,下次通過該鏈條獲取的攔截器就是“下一個攔截器”。緊接著根據(jù)當(dāng)前索引獲取當(dāng)前攔截器,將日志請求傳遞給它的同時,將“下一個攔截器”以鏈條的形式也傳遞給它。

            重構(gòu)之后的調(diào)用棧攔截器如下:

            classCallStackLogInterceptor: LogInterceptor {

            companionobject{

            privateconstvalHEADER =

            "┌──────────────────────────────────────────────────────────────────────────────────────────────────────"

            privateconstvalFOOTER =

            "└──────────────────────────────────────────────────────────────────────────────────────────────────────"

            privateconstvalLEFT_BORDER =

            privatevalblackList = listOf(

            CallStackLogInterceptor:: class. java. name,

            EasyLog::class.java.name,

            Chain::class.java.name,

            )

            }

            overridefunlog(priority: Int, tag: String, log: String, chain: Chain) {

            // 將日志請求傳遞給下一個攔截器

            chain.proceed(priority, tag, HEADER)

            // 將日志請求傳遞給下一個攔截器

            chain.proceed(priority, tag, " $LEFT_BORDER$log" )

            getCallStack(blackList).forEach {

            valcallStack = StringBuilder

            .append(LEFT_BORDER)

            .append( "\t ${it}" ).toString

            // 將日志請求傳遞給下一個攔截器

            chain.proceed(priority, tag, callStack)

            }

            // 將日志請求傳遞給下一個攔截器

            chain.proceed(priority, tag, FOOTER)

            }

            overridefunenable: Boolean{

            returntrue

            }

            }

            和之前假責(zé)任鏈的區(qū)別在于,將已經(jīng)美化過的日志傳遞給了后續(xù)攔截器,其中就包括文件日志攔截器,這樣寫入文件的日志也被美化了。

            EasyLog 也需要做相應(yīng)的改動:

            objectEasyLog {

            // 持有一組攔截器

            privatevallogInterceptors = mutableListOf<LogInterceptor>

            // 將所有日志攔截器傳遞給鏈條

            privatevalinterceptorChain = Chain(logInterceptors)

            funaddInterceptor(interceptor: LogInterceptor) {

            logInterceptors.add(interceptor)

            }

            funaddFirstInterceptor(interceptor: LogInterceptor) {

            logInterceptors.add( 0, interceptor)

            }

            funremoveInterceptor(interceptor: LogInterceptor) {

            logInterceptors.remove(interceptor)

            }

            @Synchronized

            privatefunlog(

            priority: Int,

            message: String,

            tag: String,

            varargargs: Any,

            throwable: Throwable? = null

            ) {

            varlogMessage = message.format(*args)

            if(throwable != null) {

            logMessage += getStackTraceString(throwable)

            }

            // 日志請求傳遞給鏈條

            interceptorChain.proceed(priority, tag, logMessage)

            }

            }

            這樣一來上層注入攔截的代碼不需要更改,還是按序 add 就好,將攔截器形成鏈條的復(fù)雜度被隱藏在 EasyLog 的內(nèi)部。

            高性能 I/O

            有時候為了排查線上偶現(xiàn)問題,會盡可能地在關(guān)鍵業(yè)務(wù)點打 Log 并文件化,再上傳到云以便排查。在一些高強(qiáng)度業(yè)務(wù)場景中的高頻模塊,比如直播間中的 IM,瞬間產(chǎn)生幾百上千條 Log 是家常便飯,這就對 Log 庫的性能提出了要求(CPU 和內(nèi)存)。

            Okio

            如何高性能地 I/O?

            第一個想到的是Okio。關(guān)于為啥 Okio 的性能與好于 java.io 包的流,之后會專門寫一篇分析文章。

            重新寫一個 Okio 版本的日志攔截器:

            class OkioLogInterceptor private constructor(private var dir: String) : LogInterceptor {

            private val handlerThread = HandlerThread( "log_to_file_thread")

            private val handler: Handler

            private val dispatcher: CoroutineDispatcher

            // 寫日志的開始時間

            var startTime = System.currentTimeMillis

            companion object {

            @Volatile

            private var INSTANCE: OkioLogInterceptor? = null

            fun getInstance(dir: String): OkioLogInterceptor =

            INSTANCE ?: synchronized(this) {

            INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this }

            }

            }

            init {

            handlerThread.start

            handler = Handler(handlerThread.looper)

            dispatcher = handler.asCoroutineDispatcher( "log_to_file_dispatcher")

            }

            override fun log(priority: Int, tag: String, log: String){

            if(!handlerThread.isAlive) handlerThread.start

            GlobalScope.launch(dispatcher) {

            // 使用 Okio 寫文件

            val file = File(getFileName)

            file.sink( true).buffer.use {

            it.writeUtf8( "[ $tag] $log" )

            it.writeUtf8( "\n")

            }

            // 寫日志結(jié)束時間

            if( log== "work done") Log.v( "ttaylor1", "log work is done= ${System.currentTimeMillis - startTime}" )

            }

            returnfalse

            }

            private fun getToday: String =

            SimpleDateFormat( "yyyy-MM-dd").format(Calendar.getInstance.time)

            private fun getFileName = " $dir${File.separator}${getToday}.log"

            }

            FileWriter vs Okio 性能 PK

            為了測試 Okio 和 FileWriter 的性能差異,編寫了如下測試代碼:

            classLogActivity: AppCompatActivity{

            overridefunonCreate(savedInstanceState: Bundle?) {

            super.onCreate(savedInstanceState)

            // 添加 FileWriter 或者 Okio 日志攔截器

            EasyLog.addInterceptor(OkioLogInterceptor.getInstance( this.filesDir.absolutePath))

            // EasyLog.addInterceptor(FileWriterLogInterceptor.getInstance(this.filesDir.absolutePath))

            // 重復(fù)輸出 1 萬條短 log

            MainScope.launch(Dispatchers.Default) { count ->

            repeat( 10000){

            EasyLog.v( "test log count= $count" )

            }

            EasyLog.v( "work done")

            }

            }

            }

            測試方案:重復(fù)輸入 1 萬條短 log,并且從寫第一條 log 開始計時,直到最后一條 log 的 I/O 完成時輸出耗時。

            Okio 和 FileWriter 三次測試的對比耗時如下:

            // okio

            ttaylor1: log work is done=9600

            ttaylor1: log work is done=9822

            ttaylor1: log work is done=9411

            // FileWriter

            ttaylor1: log work is done=10688

            ttaylor1: log work is done=10816

            ttaylor1: log work is done=11928

            看上去 Okio 在耗時上有微弱的優(yōu)勢。但當(dāng)我把單條 Log 的長度增加 300 倍之后,測試結(jié)果出現(xiàn)了反轉(zhuǎn):

            // FileWriter

            ttaylor1: logwork is done=13569

            ttaylor1: logwork is done=12654

            ttaylor1: logwork is done=13152

            // okio

            ttaylor1: logwork is done=14136

            ttaylor1: logwork is done=15451

            ttaylor1: logwork is done=15292

            也就是說 Okio 在高頻少量 I/O 場景性能好于 FileWriter,而高頻大量 I/O 場景下沒有性能優(yōu)勢。這個結(jié)果讓我很困惑,于是乎我在 Github 上提了 issue:

            Okio is slower when writing long strings into file frequently compared with FileWriter · Issue #1098 · square/okio

            沒想到瞬間就被回復(fù)了:

            我的提問的本意是想確認(rèn)下使用 Okio 的姿勢是否得當(dāng),但沒想到官方回答卻是:“Okio 庫就是這樣的,你的測試數(shù)據(jù)是符合預(yù)期的。我們在“用自己的 UTF-8 編碼以減少大量垃圾回收”和性能上做了權(quán)衡。所以導(dǎo)致有些測試場景下性能好,有些場景下性能沒那么好。我們的期望是在真實的業(yè)務(wù)場景下 Okio 的性能會表現(xiàn)的好?!?/p>

            不知道我翻譯的準(zhǔn)不準(zhǔn)確,若有誤翻望英語大佬指點~

            這樣的結(jié)果不能讓我滿意,隱約覺得 Okio 的使用方式有優(yōu)化空間,于是我一直凝視下面這段代碼:

            overridefunlog(priority: Int, tag: String, log: String) {

            GlobalScope.launch(dispatcher) {

            valfile = File(getFileName)

            file.sink( true).buffer.use {

            it.writeUtf8( "[ $tag] $log" )

            it.writeUtf8( "\n")

            }

            }

            returnfalse

            }

            代碼潔癖告訴我,這里有幾個可以優(yōu)化的地方:

            不用每次打 Log 都新建 File 對象。不用每次打 Log 都新建輸出流,每次打完就關(guān)閉流。

            于是我改造了 Okio 日志攔截器:

            classOkioLogInterceptorprivateconstructor( privatevardir: String) : LogInterceptor {

            privatevalhandlerThread = HandlerThread( "log_to_file_thread")

            privatevalhandler: Handler

            privatevaldispatcher: CoroutineDispatcher

            privatevarbufferedSink: BufferedSink? = null

            // 初始化時,只新建一次文件

            privatevarlogFile = File(getFileName)

            varstartTime = System.currentTimeMillis

            companionobject{

            @Volatile

            privatevarINSTANCE: OkioLogInterceptor? = null

            fungetInstance(dir: String) : OkioLogInterceptor =

            INSTANCE ?: synchronized( this) {

            INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this}

            }

            }

            init{

            handlerThread.start

            handler = Handler(handlerThread.looper)

            dispatcher = handler.asCoroutineDispatcher( "log_to_file_dispatcher")

            }

            overridefunlog(priority: Int, tag: String, log: String) {

            if(!handlerThread.isAlive) handlerThread.start

            GlobalScope.launch(dispatcher) {

            valsink = checkSink

            sink.writeUtf8( "[ $tag] $log" )

            sink.writeUtf8( "\n")

            if(log == "work done") Log.v( "ttaylor1", "log work is ok done= ${System.currentTimeMillis - startTime}" )

            }

            returnfalse

            }

            privatefungetToday: String =

            SimpleDateFormat( "yyyy-MM-dd").format(Calendar.getInstance.time)

            privatefungetFileName= " $dir${File.separator}${getToday}.log"

            // 不關(guān)流,復(fù)用 bufferedSink 對象

            privatefuncheckSink: BufferedSink {

            if(bufferedSink == null) {

            bufferedSink = logFile.appendingSink.buffer

            }

            returnbufferedSink!!

            }

            }

            新增了成員變量 logFile 和 bufferedSink,避免了每次打 Log 時重新構(gòu)建它們。而且我沒有在每次打完 Log 就把輸出流關(guān)閉掉。重新跑一下測試代碼,奇跡發(fā)生了:

            ttaylor1: log work isdone= 832

            1萬條 Log 寫入的時間從 9411 ms 縮短到 832 ms,整整縮短了 11 倍?。?/strong>

            這個結(jié)果有點難以置信,我連忙從手機(jī)中拉取了日志文件,非常擔(dān)心是因為程序 bug 導(dǎo)致日志輸出不全。

            果不其然,正確的日志文件應(yīng)該包含 1 萬行,而現(xiàn)在只有 9901 行。

            轉(zhuǎn)念一下,不對啊,一次flush都沒有調(diào)用,為啥文件中會有日志?

            哦~,肯定是因為內(nèi)存中的輸出緩沖區(qū)滿了之后自動進(jìn)行了 flush 操作。

            然后我用同樣的思路寫了一個 FileWriter 的版本,跑了一下測試代碼:

            ttaylor1: FileWriter log work isdone= 1439

            這下可把 FileWriter 和 Okio 的差距顯現(xiàn)出來了,將近一倍的速度差距。算是為技術(shù)選型 Okio 提供了有利的數(shù)據(jù)支持!

            感知日志打印結(jié)束?

            但還有一個問題急需解決:Log 輸出不全。

            這是因為當(dāng)最后一條日志寫入緩沖區(qū)時,若緩沖區(qū)未滿就不會執(zhí)行 flush 操作。這種情況下需要手動 flush。

            如何感知到最后一條 Log?做不到!因為打 Log 是業(yè)務(wù)層行為,底層 Log 庫無法感知上層行為。

            這個場景讓我聯(lián)想到 “搜索框防抖”,即當(dāng)你在鍵入關(guān)鍵詞后,自動發(fā)起搜索的行為。

            用流的思想理解上面的場景:輸入框是流數(shù)據(jù)的生產(chǎn)者,其內(nèi)容每變化一次,就是在流上生產(chǎn)了一個新數(shù)據(jù)。但并不是每一個數(shù)據(jù)都需要被消費,所以得做“限流”,即丟棄一切發(fā)射間隔過短的數(shù)據(jù),直到生產(chǎn)出某個數(shù)據(jù)之后一段時間內(nèi)不再有新數(shù)據(jù)。

            Flow 的操作符 debounce 就非常契合這個場景。

            它的背后機(jī)制是:每當(dāng)流產(chǎn)生新數(shù)據(jù)時,開啟倒計時,如果在倒計時歸零之前沒有新數(shù)據(jù),則將最后那個數(shù)據(jù)發(fā)射出去,否則重新開啟倒計時。

            知道了背后的機(jī)制,就不需要拘泥于具體的實現(xiàn)方式,使用 Android 中的消息機(jī)制也能實現(xiàn)同樣的效果(日志攔截器正好使用了 HandlerThread,現(xiàn)成的消息機(jī)制)。

            每當(dāng)新日志到來時,將其封裝為一條消息發(fā)送給 Handler,緊接著再發(fā)送一條延遲消息,若有后續(xù)日志,則移除延遲消息,并重發(fā)一條新延遲消息。若無后續(xù)日志,Handler 終將收到延遲消息,此時就執(zhí)行 flush 操作。

            (Android 中判定 Activity 生命周期超時也是用這套機(jī)制,感興趣的可以搜索 com.android.server.wm.ActivityStack.STOP_TIMEOUT_MSG)

            改造后的日志攔截器如下:

            classOkioLogInterceptorprivateconstructor( privatevardir: String) : LogInterceptor {

            privatevalhandlerThread = HandlerThread( "log_to_file_thread")

            privatevalhandler: Handler

            privatevarstartTime = System.currentTimeMillis

            privatevarbufferedSink: BufferedSink? = null

            privatevarlogFile = File(getFileName)

            // 日志消息處理器

            valcallback = Handler.Callback { message ->

            valsink = checkSink

            when(message.what) {

            // flush 日志

            TYPE_FLUSH -> {

            sink.use {

            it.flush

            bufferedSink = null

            }

            }

            // 寫日志

            TYPE_LOG -> {

            vallog = message.obj asString

            sink.writeUtf8(log)

            sink.writeUtf8( "\n")

            }

            }

            // 統(tǒng)計耗時

            if(message.obj as? String == "work done") Log.v(

            "ttaylor1",

            "log work is done= ${System.currentTimeMillis - startTime}"

            )

            false

            }

            companionobject{

            privateconstvalTYPE_FLUSH = - 1

            privateconstvalTYPE_LOG = 1

            // 若 3000 ms 內(nèi)沒有新日志請求,則執(zhí)行 flush

            privateconstvalFLUSH_LOG_DELAY_MILLIS = 3000L

            @Volatile

            privatevarINSTANCE: OkioLogInterceptor? = null

            fungetInstance(dir: String) : OkioLogInterceptor =

            INSTANCE ?: synchronized( this) {

            INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this}

            }

            }

            init{

            handlerThread.start

            handler = Handler(handlerThread.looper, callback)

            }

            overridefunlog(priority: Int, tag: String, log: String) {

            if(!handlerThread.isAlive) handlerThread.start

            handler.run {

            // 移除上一個延遲消息

            removeMessages(TYPE_FLUSH)

            // 將日志作為一條消息發(fā)送出去

            obtainMessage(TYPE_LOG, "[ $tag] log" ).sendToTarget

            // 構(gòu)建延遲消息并發(fā)送

            valflushMessage = handler.obtainMessage(TYPE_FLUSH)

            sendMessageDelayed(flushMessage, FLUSH_LOG_DELAY_MILLIS)

            }

            returnfalse

            }

            privatefungetToday: String =

            SimpleDateFormat( "yyyy-MM-dd").format(Calendar.getInstance.time)

            privatefungetFileName= " $dir${File.separator}${getToday}.log"

            privatefuncheckSink: BufferedSink {

            if(bufferedSink == null) {

            bufferedSink = logFile.appendingSink.buffer

            }

            returnbufferedSink!!

            }

            }

            Talk is cheap, show me the code

            EasyLog 已上傳 GitHub,鏈接 https://github.com/wisdomtl/EasyLog

            總結(jié)

            “簡單”和“彈性”是庫設(shè)計中首要關(guān)注的兩個方面。

            Kotlin 有諸多語法特性能極大地降低代碼的復(fù)雜度。

            真責(zé)任鏈模式非常適用于為日志庫提供彈性。它使得動態(tài)為日志庫新增功能成為可能,它使得每次的處理結(jié)果得以在傳遞給后續(xù)處理者。

            用HandlerThread實現(xiàn)異步串行日志輸出。

            用Okio實現(xiàn)高性能的日志文件化。

            在高頻日志文件化的場景下,復(fù)用輸出流能極大地提高性能。同時需要延遲消息機(jī)制保證日志的完整性。

            為了防止失聯(lián),歡迎關(guān)注我防備的小號

            微信改了推送機(jī)制,真愛請星標(biāo)本公號?? 返回搜狐,查看更多

            責(zé)任編輯:

            掃描二維碼推送至手機(jī)訪問。

            版權(quán)聲明:本文由財神資訊-領(lǐng)先的體育資訊互動媒體轉(zhuǎn)載發(fā)布,如需刪除請聯(lián)系。

            本文鏈接:http://thecityplacetownhomes.com/?id=11888

            “場景面試題:怎么寫一個 Android 日志庫?” 的相關(guān)文章

            湖南參與制定國際、國家標(biāo)準(zhǔn)3000余項

            湖南參與制定國際、國家標(biāo)準(zhǔn)3000余項

            11月25日,“世界標(biāo)準(zhǔn)日”主題宣傳暨標(biāo)準(zhǔn)進(jìn)園區(qū)活動在長沙市芙蓉區(qū)芙蓉標(biāo)準(zhǔn)化小鎮(zhèn)舉行。紅網(wǎng)時刻新聞11月26日訊(記者 劉璇)11月25日,“世界標(biāo)準(zhǔn)日”主題宣傳暨標(biāo)準(zhǔn)進(jìn)園區(qū)活動在長沙市芙蓉區(qū)芙蓉標(biāo)準(zhǔn)化小鎮(zhèn)舉行。記者獲悉,近年來,湖南深入實施標(biāo)準(zhǔn)化戰(zhàn)略,助力湖南高質(zhì)量發(fā)展。截至目前,全省企...

            法語新聞詞匯選摘(二百四十八):中科院智能乒乓球桌;?挑戰(zhàn)與機(jī)遇共存;NASA展示將發(fā)射到太空的“機(jī)器人酒店”

            原標(biāo)題:法語新聞詞匯選摘(二百四十八):中科院智能乒乓球桌;?挑戰(zhàn)與機(jī)遇共存;NASA展示將發(fā)射到太空的“機(jī)器人酒店” 中科院智能乒乓球桌 1. dispositif de stockage dénergie conventionnel 常規(guī)儲能裝置 2. na...

            弗里克:最愛的蒂亞戈不在西班牙隊,所以可以想象他們的實力

            弗里克:最愛的蒂亞戈不在西班牙隊,所以可以想象他們的實力

              直播吧11月27日訊 德國隊即將在世界杯小組賽第二場交手西班牙,賽前德國主帥弗里克出席新聞發(fā)布會。   未帶球員參加發(fā)布會   我不想要任何一個球員與我一同經(jīng)歷如此長的車程來到這里,所有球員都很重要,這就是為何我不想帶球員一起來。他們現(xiàn)在應(yīng)該為比賽做訓(xùn)練準(zhǔn)備。...

            乒乓球新賽事WTT世界杯決賽:國乒隊員悉數(shù)晉級八強(qiáng)

            乒乓球新賽事WTT世界杯決賽:國乒隊員悉數(shù)晉級八強(qiáng)

            新華社新加坡12月4日電 WTT(世界乒乓球職業(yè)大聯(lián)盟)世界杯決賽4日在新加坡開賽,出戰(zhàn)當(dāng)日首輪賽事的中國隊運動員擊敗各自對手后挺進(jìn)八強(qiáng)。 樊振東在比賽中慶祝得分。新華社發(fā)(鄧智煒攝) WTT...

            乒聯(lián)最新世界排名出爐!樊振東奪金居首,陳夢仍第一小棗跌至第十

            乒聯(lián)最新世界排名出爐!樊振東奪金居首,陳夢仍第一小棗跌至第十

            2021休斯敦世乒賽圓滿結(jié)束,中國乒乓球隊5項收獲4金1銀5.5銅,而國際乒聯(lián)也公布了最新一期的世界排名。此次國乒選手樊振東成為了世乒賽歷史上第35位男單冠軍。而樊振東也為國乒拿到了第21座圣·勃萊德杯。當(dāng)然了,最新一期的世界排名中,國乒選手樊振東排名男單世界第一,女單陳夢依舊也是世界第一...

            國乒球員最新世界排名出爐:樊振東驟降1400分,林高遠(yuǎn)飆升8名次

            國乒球員最新世界排名出爐:樊振東驟降1400分,林高遠(yuǎn)飆升8名次

              北京時間2022年7月26日,國際乒聯(lián)官網(wǎng)更新了球員世界排名,其中國乒現(xiàn)役“一哥”樊振東驟降1500分;林高遠(yuǎn)則攀升8個名次。具體詳情如下:   國乒球員最新世界排名如何?   根據(jù)國際乒聯(lián)官網(wǎng)可知,在男單球員中,排名前十的球員分別為:1、樊振東(國乒,550...

            ?