替換so文件來(lái)動(dòng)態(tài)替換Flutter代碼實(shí)現(xiàn)詳解
一、Flutter代碼的啟動(dòng)起點(diǎn)
我們?cè)诙鄶?shù)的業(yè)務(wù)場(chǎng)景下,使用的都是FlutterActivity
、FlutterFragment
。在在背后,我們知道有著FlutterEnigine、DartExecutor等等多個(gè)部件在支持它們的工作。我們所要探究的,就是,它們是如何啟動(dòng)的,Dart代碼是從何而來(lái)的,以實(shí)現(xiàn)動(dòng)態(tài)替換libapp.so。
以官方的計(jì)數(shù)器Demo為例,默認(rèn)的Activity宿主,是實(shí)現(xiàn)了FlutterActivity的子類,對(duì)于一個(gè)Activity,我們最應(yīng)該關(guān)心的就是它的onCreate方法:
- FlutterActivity# onCreate
protected void onCreate(@Nullable Bundle savedInstanceState) { switchLaunchThemeForNormalTheme(); super.onCreate(savedInstanceState); delegate = new FlutterActivityAndFragmentDelegate(this); delegate.onAttach(this); delegate.onRestoreInstanceState(savedInstanceState); lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); configureWindowForTransparency(); setContentView(createFlutterView()); configureStatusBarForFullscreenFlutterExperience(); }
其實(shí)過(guò)程很簡(jiǎn)單,F(xiàn)lutterActivity在這里做了一些主題的設(shè)置,因?yàn)楫吘笷lutterActivity也是一個(gè)常規(guī)的Activity,它就必須按照Android的Activity的一些規(guī)范來(lái)進(jìn)行設(shè)置。
第三行代碼開(kāi)始,就創(chuàng)建了一個(gè)我們所說(shuō)的**FlutterActivityAndFragmentDelegate
**對(duì)象,F(xiàn)lutterActivity將絕大多數(shù)的Flutter初始化相關(guān)邏輯委托給了它,而自身則專注于設(shè)置主題、窗口、StatusBar等等。
我們對(duì)delegate.onAttach(
this
);
這一行代碼的跟蹤,最終能走到如下的一個(gè)創(chuàng)建流程:
FlutterActivity-> FlutterActivityAndFragmentDelegate-> onAttach()-> setupFlutterEngine-> 1.嘗試去Cache中獲取Engine 2.嘗試從Host中獲取Engine 3.都沒(méi)有的話創(chuàng)建一個(gè)新的Engine-> Engine #Constructor-> 1. 會(huì)對(duì)Assets、DartExecutor、各種Channel、FlutterJNI做處理 2. 還會(huì)對(duì)FlutterLoader做處理-> startInitialization方法做初始化 -> 1. 必須在主線程初始化Flutter -> 2. 先檢查settings變量; -> 3. 獲取全局的ApplicationContext防止內(nèi)存泄漏 -> 4. VsyncWaiter對(duì)象的初始化 -> 5. 最后會(huì)生成一個(gè)initTask交給線程池去執(zhí)行
1.1 initTask對(duì)象
initTask是一個(gè)Callable對(duì)象,和Runnable類似的,我們可以將它理解成一個(gè)任務(wù),也就是一段代碼,他最終會(huì)被交給線程池去執(zhí)行:
initResultFuture = Executors.newSingleThreadExecutor().submit(initTask);
initTask的代碼如下
// Use a background thread for initialization tasks that require disk access. Callable<InitResult> initTask = new Callable<InitResult>() { @Override public InitResult call() { ResourceExtractor resourceExtractor = initResources(appContext); flutterJNI.loadLibrary(); // Prefetch the default font manager as soon as possible on a background thread. // It helps to reduce time cost of engine setup that blocks the platform thread. Executors.newSingleThreadExecutor() .execute( new Runnable () { @Override public void run () { flutterJNI.prefetchDefaultFontManager(); } } ); if (resourceExtractor != null) { resourceExtractor.waitForCompletion(); } return new InitResult( PathUtils.getFilesDir(appContext), PathUtils.getCacheDirectory(appContext), PathUtils.getDataDirectory(appContext) ); } };
我們可以抓一下其中的關(guān)鍵字:
- ResourceExtractor
- FlutterJNI.loadLibrary
- FlutterJNI.prefetchDefaultFontManager
- PathUtils
不難發(fā)現(xiàn),主要是在做一些資源的預(yù)取。
ResourceExtractor主要是針對(duì)在DEBUG或者是JIT模式下,針對(duì)安裝包內(nèi)資源的提取邏輯。
在DEBUG或者JIT模式下,需要提取Assets目錄下的資源文件到存儲(chǔ)中,Assets本質(zhì)上還是Zip壓縮包的一部分,沒(méi)有自己的物理路徑,所以需要提取,并返回真真實(shí)的物理路徑。在DEBUG和JIT模式下,F(xiàn)lutterSDK和業(yè)務(wù)代碼將被構(gòu)建成Kernel格式的二進(jìn)制文件,Engine將通過(guò)文件內(nèi)存映射的方式進(jìn)行加載。
詳見(jiàn):「三、libflutter.so和libapp.so」
1.2 ResourceExtractor
libflutter.so和libapp.so
在DEBUG | JIT模式下,我們是沒(méi)有l(wèi)ibapp.so的,而在release模式下,是有l(wèi)ibapp.so文件的,我們分別解包兩個(gè)不同的Apk文件,可以很清楚地看到這一點(diǎn):
我們知道,libflutter.so是存放flutter的一些基礎(chǔ)類庫(kù)的so文件,而libapp.so則是存放我們業(yè)務(wù)代碼的so文件,那如果在DEBUG|JIT模式下,沒(méi)有l(wèi)ibapp.so,那么我們的業(yè)務(wù)代碼存儲(chǔ)在哪里呢?
此時(shí),我們就要看看ResourceExtractor的initResources方法,究竟干了些什么:
/** Extract assets out of the APK that need to be cached as uncompressed files on disk. */ private ResourceExtractor initResources(@NonNull Context applicationContext) { ResourceExtractor resourceExtractor = null; if (BuildConfig.DEBUG || BuildConfig.JIT_RELEASE) { final String dataDirPath = PathUtils.getDataDirectory(applicationContext); final String packageName = applicationContext.getPackageName(); final PackageManager packageManager = applicationContext.getPackageManager(); final AssetManager assetManager = applicationContext.getResources().getAssets(); resourceExtractor = new ResourceExtractor(dataDirPath, packageName, packageManager, assetManager); // In debug/JIT mode these assets will be written to disk and then // mapped into memory so they can be provided to the Dart VM. resourceExtractor .addResource(fullAssetPathFrom(flutterApplicationInfo.vmSnapshotData)) .addResource(fullAssetPathFrom(flutterApplicationInfo.isolateSnapshotData)) .addResource(fullAssetPathFrom(DEFAULT_KERNEL_BLOB)); resourceExtractor.start(); } return resourceExtractor; }
其中的addResource方法,分別提供了VM的快照數(shù)據(jù)、iSolate的快照數(shù)據(jù)DEFAULT_KERNEL_BLOB的數(shù)據(jù)。因?yàn)镕lutter本身支持熱重載的特性,保存狀態(tài)和快照(Snapshot)之間必然是不可分割的。
而DEFAULT_KERNEL_BLOB是一個(gè)字符串常量: "kernel_blob.bin",結(jié)合前面的內(nèi)容:
FlutterSDK和業(yè)務(wù)代碼將被構(gòu)建成Kernel格式的二進(jìn)制文件
我們有理由猜測(cè), "kernel_blob.bin" ,就是我們的業(yè)務(wù)代碼,F(xiàn)lutter是支持邏輯代碼熱重載的,所以這個(gè)字面量加載的資源同樣可能會(huì)被重新加載。
這也是為什么,如果我們?cè)赟tate中,新增了某個(gè)變量作為Widget的某個(gè)狀態(tài),在initState中調(diào)用了,然后使用熱重載之后,會(huì)導(dǎo)致State中找不到這個(gè)變量,因?yàn)閕nitState在初次啟動(dòng)時(shí)就被調(diào)用過(guò)了,后續(xù)的熱重載只會(huì)將之前的Snapshot恢復(fù)回來(lái),而不會(huì)走initState的邏輯。
我們可以在app-debug.apk的assets中,找到"kernel_blob.bin"文件,同樣也可以找到isolate_snapshot_data、vm_snapshot_data文件,所以ResourceExtractor加載的,基本上都是這個(gè)文件夾中的文件。
但是,在非DEBUG|JIT模式下,就不需要通過(guò)ResourceExtractor來(lái)進(jìn)行加載了。
回到initTask方法,只在resourceExtractor != null時(shí),會(huì)去等待它的完成。
ResourceExtractor resourceExtractor = initResources(appContext); flutterJNI.loadLibrary(); // Prefetch the default font manager as soon as possible on a background thread. // It helps to reduce time cost of engine setup that blocks the platform thread. Executors.newSingleThreadExecutor() .execute( new Runnable() { @Override public void run() { flutterJNI.prefetchDefaultFontManager(); } }); if (resourceExtractor != null) { resourceExtractor.waitForCompletion(); }
1.3 FlutterJNI#loadLibrary
public void loadLibrary() { if (FlutterJNI.loadLibraryCalled) { Log.w(TAG, "FlutterJNI.loadLibrary called more than once" ); } System.loadLibrary( "flutter" ); FlutterJNI.loadLibraryCalled = true; }
代碼比較簡(jiǎn)單,無(wú)非就是調(diào)用System.loadLibrary去加載Library文件。需要注意的是,表面上找到是flutter,但是在Native(C++)層中,會(huì)為它拼接上前綴和后綴:lib和.so,所以,實(shí)際上load行為查找的是位于apk包下的lib目錄下的對(duì)應(yīng)架構(gòu)文件夾下的libflutter.so
。
initTask任務(wù)提交給線程池之后,就相當(dāng)于startInitialization走完了。
你會(huì)發(fā)現(xiàn)有個(gè)問(wèn)題,在Debug模式下,我們加載業(yè)務(wù)代碼是從二進(jìn)制文件:"kernel_blob.bin"中加載的,而Release模式下,實(shí)在libapp.so中加載的,上面已經(jīng)出現(xiàn)了加載"kernel_blob.bin"和libflutter.so ,那么在release模式下,另一個(gè)Library文件:libapp.so是什么時(shí)候加載的呢?
所以,就要進(jìn)入我們的第二個(gè)關(guān)鍵方法:ensureInitializationComplete
二、ensureInitializationComplete
實(shí)際上,ensureInitializationComplete和startInitialization在FlutterEngine的初始化代碼中
flutterLoader.startInitialization(context.getApplicationContext()); flutterLoader.ensureInitializationComplete(context, dartVmArgs);
代碼一百多行,但是大多都是一些配置性的代碼:
public void ensureInitializationComplete( @NonNull Context applicationContext, @Nullable String[] args) { if (initialized) { return; } if (Looper.myLooper() != Looper.getMainLooper()) { throw new IllegalStateException( "ensureInitializationComplete must be called on the main thread" ); } if (settings == null) { throw new IllegalStateException( "ensureInitializationComplete must be called after startInitialization" ); } try { InitResult result = initResultFuture.get(); List<String> shellArgs = new ArrayList<>(); shellArgs.add( "--icu-symbol-prefix=_binary_icudtl_dat" ); shellArgs.add( "--icu-native-lib-path=" + flutterApplicationInfo.nativeLibraryDir + File.separator + DEFAULT_LIBRARY); if (args != null) { Collections.addAll(shellArgs, args); } String kernelPath = null; if (BuildConfig.DEBUG || BuildConfig.JIT_RELEASE) { String snapshotAssetPath = result.dataDirPath + File.separator + flutterApplicationInfo.flutterAssetsDir; kernelPath = snapshotAssetPath + File.separator + DEFAULT_KERNEL_BLOB; shellArgs.add( "--" + SNAPSHOT_ASSET_PATH_KEY + "=" + snapshotAssetPath); shellArgs.add( "--" + VM_SNAPSHOT_DATA_KEY + "=" + flutterApplicationInfo.vmSnapshotData); shellArgs.add( "--" + ISOLATE_SNAPSHOT_DATA_KEY + "=" + flutterApplicationInfo.isolateSnapshotData); } else { shellArgs.add( "--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName); // Most devices can load the AOT shared library based on the library name // with no directory path. Provide a fully qualified path to the library // as a workaround for devices where that fails. shellArgs.add( "--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.nativeLibraryDir + File.separator + flutterApplicationInfo.aotSharedLibraryName); } shellArgs.add( "--cache-dir-path=" + result.engineCachesPath); if (!flutterApplicationInfo.clearTextPermitted) { shellArgs.add( "--disallow-insecure-connections" ); } if (flutterApplicationInfo.domainNetworkPolicy != null) { shellArgs.add( "--domain-network-policy=" + flutterApplicationInfo.domainNetworkPolicy); } if (settings.getLogTag() != null) { shellArgs.add( "--log-tag=" + settings.getLogTag()); } ApplicationInfo applicationInfo = applicationContext .getPackageManager() .getApplicationInfo( applicationContext.getPackageName(), PackageManager.GET_META_DATA); Bundle metaData = applicationInfo.metaData; int oldGenHeapSizeMegaBytes = metaData != null ? metaData.getInt(OLD_GEN_HEAP_SIZE_META_DATA_KEY) : 0; if (oldGenHeapSizeMegaBytes == 0) { // default to half of total memory. ActivityManager activityManager = (ActivityManager) applicationContext.getSystemService(Context.ACTIVITY_SERVICE); ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); activityManager.getMemoryInfo(memInfo); oldGenHeapSizeMegaBytes = (int) (memInfo.totalMem / 1e6 / 2); } shellArgs.add( "--old-gen-heap-size=" + oldGenHeapSizeMegaBytes); if (metaData != null && metaData.getBoolean(ENABLE_SKPARAGRAPH_META_DATA_KEY)) { shellArgs.add( "--enable-skparagraph" ); } long initTimeMillis = SystemClock.uptimeMillis() - initStartTimestampMillis; flutterJNI.init( applicationContext, shellArgs.toArray(new String[0]), kernelPath, result.appStoragePath, result.engineCachesPath, initTimeMillis); initialized = true; } catch (Exception e) { Log.e(TAG, "Flutter initialization failed." , e); throw new RuntimeException(e); } }
顯然,ensureInitializationComplete也必須在主線程中進(jìn)行調(diào)用,并且必須在startInitialization之后進(jìn)行調(diào)用。此外,我們要注意另外一個(gè)東西:shellArgs。
2.1 ShellArgs
Shell是什么大家并不陌生,在計(jì)算機(jī)中,Shell通常作為系統(tǒng)調(diào)用和用戶操作之間的那么個(gè)東西,它存在的形式在Linux/Mac中一般就是一個(gè)Shell軟件,通常運(yùn)行在終端當(dāng)中(你可以粗略地就將Shell 和終端劃等號(hào) )。
所以,F(xiàn)lutter的Shell自然而然地旨在設(shè)置Flutter運(yùn)行的一個(gè)「基底」,ShellArgs,則是我們使用這么個(gè)「基底」的參數(shù)。
和之前提到的ResourceExtractor在JIT|DEBUG模式下主動(dòng)去加載VM和Isoalte快照數(shù)據(jù)類似地,ShellArgs會(huì)在DEBUG和JIT模式下,去設(shè)置VM快照數(shù)據(jù)、Isolate快照數(shù)據(jù)和Kernel的地址。
別忘了,Kernel即上述的“kernel_blob.bin”二進(jìn)制文件,是在Debug階段我們的業(yè)務(wù)代碼,和libapp.so是相對(duì)的。
而在除上述之外的條件下,F(xiàn)lutter設(shè)置了一個(gè)AOT_SHARED_LIBRARY_NAME的路徑:
shellArgs.add( "--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName); shellArgs.add( "--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.nativeLibraryDir + File.separator + flutterApplicationInfo.aotSharedLibraryName);
在運(yùn)行時(shí),這個(gè)向shareArgs這個(gè)List中添加內(nèi)容的兩個(gè)字符串的內(nèi)容,大致上就是指定了裝載在系統(tǒng)的Apk安裝包中的so文件的路徑。
--aot-shared-library-name=libapp.so --aot-shared-library-name=/data/app/~~RjRJYnLhVBHYW8pHHPeX2g==/com.example.untitled1-wcMTYW1VkfGA2LxW62gUFA==/lib/arm64/libapp.so
因?yàn)門inker本身是支持二進(jìn)制SO庫(kù)的動(dòng)態(tài)化的,之前嘗試過(guò)去動(dòng)態(tài)修改aotSharedLibraryName
的值和路徑,希望FlutterLoader從該地址去加載libapp.so
,以實(shí)現(xiàn)Android側(cè)借助Tinker熱修復(fù)Flutter代碼,但是并沒(méi)有細(xì)看源碼,打了N個(gè)Debug包去測(cè)試,結(jié)果現(xiàn)在發(fā)現(xiàn)這邏輯壓根沒(méi)走。
除了上述的兩個(gè)libapp.so的名稱和路徑之外,在DEBUG | JIT模式下的ShellArgs的全家福大致如下:
其實(shí)你仔細(xì)看看,上述的Kernel的Path并沒(méi)有在這里面,因?yàn)樗鳛閰?shù),傳遞給了flutterJNI.init函數(shù)。
三、實(shí)踐:自定義libapp.so的加載
至此,我們今天最開(kāi)始的一個(gè)話題:Embdder和代碼Dart代碼從何而來(lái), 便有了結(jié)果 。結(jié)合上述的內(nèi)容,我們可以做一個(gè)小小的實(shí)踐,我們通過(guò)傳入ShellArgs,來(lái)加載指定的 libapp.so
文件。
回到我們最初的流程:
FlutterActivity-> FlutterActivityAndFragmentDelegate-> onAttach()-> setupFlutterEngine-> …… startInitialization ensureInitializationComplete // alpha
我們需要在上述的過(guò)程的alpha之前,完成對(duì)***AOT_SHARED_LIBRARY_NAME
*** 對(duì)應(yīng)的路徑(一模一樣,也是 AOT_SHARED_LIBRARY_NAME
)這兩個(gè)字符串的內(nèi)容替換,比如:
--aot-shared-library-name=libapp.so --aot-shared-library-name= /data/app/~~RjRJYnLhVBHYW8pHHPeX2g==/com.example.untitled1-wcMTYW1VkfGA2LxW62gUFA==/lib/arm64/libapp.so
我們希望替換成:
--aot-shared-library-name=libfixedapp.so --aot-shared-library-name= /temp/lib/arm64/libfixedapp.so
3.1 flutterApplicationInfo和FlutterActivity#getShellArgs()
這是FlutterLoader的一個(gè)實(shí)例對(duì)象,它在startInitialization階段被賦值:
public void startInitialization(@NonNull Context applicationContext, @NonNull Settings settings) { // …… try { final Context appContext = applicationContext.getApplicationContext(); // …… flutterApplicationInfo = ApplicationInfoLoader.load(appContext); ……
所以,我們只需要在合適的時(shí)機(jī)去修改這個(gè)值即可。
但是并沒(méi)有合適的時(shí)機(jī),因?yàn)镕lutter并沒(méi)有為我們提供可以侵入去反射設(shè)置它的時(shí)機(jī),如果在startInitialization,我們唯一可以侵入的時(shí)機(jī)是attach()函數(shù),但是會(huì)讓我們反射設(shè)置的值被覆蓋掉。
但是,我們關(guān)注一下,在setupFlutterEngine時(shí),我們new FlutterEngine的參數(shù):
flutterEngine = new FlutterEngine( host.getContext(), host.getFlutterShellArgs().toArray(), /*automaticallyRegisterPlugins=*/ false, /*willProvideRestorationData=*/ host.shouldRestoreAndSaveState());
此處的host,就是我們的FlutterActivity,因?yàn)镕lutterActivity本身就是FlutterActivityAndFragmentDelegate.Host
接口的實(shí)現(xiàn)類,而這個(gè)host.getFlutterShellArgs().toArray()
,最終會(huì)作為我們?cè)贔lutterActivity預(yù)設(shè)的參數(shù),在所其他系統(tǒng)預(yù)設(shè)參數(shù)被加入之前被加入到我們的shellArgs數(shù)組中。
所以,我們?cè)贔lutterActivity的子類,也就是MainActivity下,重寫getFlutterShellArgs()方法:
class MainActivity: FlutterActivity() { override fun getFlutterShellArgs(): FlutterShellArgs { return super.getFlutterShellArgs().apply { this.add( "--aot-shared-library-name=libfixedapp.so" ) this.add( "--aot-shared-library-name=/data/data/com.example.untitled1/libfixedapp.so" ) } } }
我們可以在debug模式下debug,看看有沒(méi)有效果:
顯然,是有效果的。
因?yàn)橹荒軓膸讉€(gè)特定的目錄中去加載so庫(kù)文件,我們必須將補(bǔ)丁SO文件放在/data/data/com.example.untitled1
對(duì)應(yīng)的目錄之下。
接下來(lái),我們先寫一個(gè)有bug的Flutter代碼,我們把標(biāo)題改成:This is Counter Title with bug
, 并且新增一個(gè) _decrementCounter()
, 并把計(jì)數(shù)器的加法按鈕對(duì)應(yīng)的增加按鈕,改成減少調(diào)用。
然后在Flutter項(xiàng)目根目錄使用安裝Release包:
flutter build apk --release adb install build/app/outputs/flutter-apk/app-release.apk
然后我們修復(fù)Bug,將代碼恢復(fù)到最開(kāi)始的默認(rèn)狀態(tài),然后:
flutter build apk --release open build/app/outputs/flutter-apk/
解壓apk,然后把對(duì)應(yīng)的so文件移出來(lái),放到對(duì)應(yīng)的文件夾下: /data/data/com.example.untitled1/libfixedapp.so
。完成之后,重新啟動(dòng)程序,即可從新的、我們指定的路徑加載新的 libapp.so
了:
以上就是替換so文件來(lái)動(dòng)態(tài)替換Flutter代碼實(shí)現(xiàn)詳解的詳細(xì)內(nèi)容,更多關(guān)于so文件動(dòng)態(tài)替換Flutter代碼的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android自定義多節(jié)點(diǎn)進(jìn)度條顯示的實(shí)現(xiàn)代碼(附源碼)
這篇文章主要介紹了Android自定義多節(jié)點(diǎn)進(jìn)度條顯示的實(shí)現(xiàn)代碼,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2017-03-03解決Android中自定義DialogFragment解決寬度和高度問(wèn)題
Android中自定義DialogFragment解決寬度和高度問(wèn)題但是我們很多時(shí)候想把DialogFragment的高度固定,那么我們需要設(shè)置DialogFragment的高度,在Fragment的onResume()聲明周期方法中設(shè)置window的寬高即可2017-12-12Android 雙擊Back鍵退出應(yīng)用的實(shí)現(xiàn)方法
這篇文章主要介紹了Android 雙擊Back鍵退出應(yīng)用的實(shí)現(xiàn)方法的相關(guān)資料,希望通過(guò)本文能幫助到大家,讓大家實(shí)現(xiàn)這樣的功能,需要的朋友可以參考下2017-10-10Android自定義View實(shí)現(xiàn)帶數(shù)字的進(jìn)度條實(shí)例代碼
這篇文章主要介紹了Android自定義View實(shí)現(xiàn)帶數(shù)字的進(jìn)度條實(shí)例代碼的相關(guān)資料,需要的朋友可以參考下2016-03-03Android使用kotlin實(shí)現(xiàn)多行文本上下滾動(dòng)播放
這篇文章主要為大家詳細(xì)介紹了Android使用kotlin實(shí)現(xiàn)多行文本的上下滾動(dòng)播放,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-01-01Android Studio自定義萬(wàn)能注釋模板與創(chuàng)建類,方法注釋模板操作
這篇文章主要介紹了Android Studio自定義萬(wàn)能注釋模板與創(chuàng)建類,方法注釋模板操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-03-03android橫豎屏切換時(shí)候Activity的生命周期
曾經(jīng)遇到過(guò)一個(gè)面試題,讓你寫出橫屏切換豎屏Activity的生命周期?,F(xiàn)在給大家分析一下他切換時(shí)具體的生命周期是怎么樣的2013-01-01MTK Android平臺(tái)開(kāi)發(fā)流程
這篇文章主要介紹了MTK在Android平臺(tái)開(kāi)發(fā)的流程,一共分析了44個(gè)步驟,需要的朋友學(xué)習(xí)下吧。2017-12-12Android自定義View實(shí)現(xiàn)課程表表格
這篇文章主要為大家詳細(xì)介紹了Android自定義View實(shí)現(xiàn)課程表表格,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-03-03Android Canvas的drawText()與文字居中方案詳解
這篇文章主要給大家介紹了關(guān)于Android Canvas的drawText()與文字居中方案的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)各位Android開(kāi)發(fā)者們具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-12-12