淺談Android插件化
一、認(rèn)識(shí)插件化
1.1 插件化起源
插件化技術(shù)最初源于免安裝運(yùn)行 Apk的想法,這個(gè)免安裝的 Apk 就可以理解為插件,而支持插件的 app 我們一般叫 宿主。
想必大家都知道,在 Android
系統(tǒng)中,應(yīng)用是以 Apk 的形式存在的,應(yīng)用都需要安裝才能使用。但實(shí)際上 Android 系統(tǒng)安裝應(yīng)用的方式相當(dāng)簡(jiǎn)單,其實(shí)就是把應(yīng)用 Apk 拷貝到系統(tǒng)不同的目錄下、然后把 so 解壓出來(lái)而已。
常見(jiàn)的應(yīng)用安裝目錄有:
/system/app
:系統(tǒng)應(yīng)用/system/priv-app
:系統(tǒng)應(yīng)用/data/app
:用戶(hù)應(yīng)用
那可能大家會(huì)想問(wèn),既然安裝這個(gè)過(guò)程如此簡(jiǎn)單,Android
是怎么運(yùn)行應(yīng)用中的代碼的呢,我們先看 Apk
的構(gòu)成,一個(gè)常見(jiàn)的 Apk 會(huì)包含如下幾個(gè)部分:
classes.dex
:Java 代碼字節(jié)碼res
:資源文件lib
:so 文件assets
:靜態(tài)資產(chǎn)文件AndroidManifest.xml
:清單文件
其實(shí) Android
系統(tǒng)在打開(kāi)應(yīng)用之后,也只是開(kāi)辟進(jìn)程,然后使用 ClassLoader
加載 classes.dex
至進(jìn)程中,執(zhí)行對(duì)應(yīng)的組件而已。
那大家可能會(huì)想一個(gè)問(wèn)題,既然 Android
本身也是使用類(lèi)似反射的形式加載代碼執(zhí)行,憑什么我們不能執(zhí)行一個(gè) Apk 中的代碼呢?
1.2 插件化優(yōu)點(diǎn)
插件化讓 Apk
中的代碼(主要是指 Android 組件)能夠免安裝運(yùn)行,這樣能夠帶來(lái)很多收益:
- 減少安裝Apk的體積、按需下載模塊
- 動(dòng)態(tài)更新插件
- 宿主和插件分開(kāi)編譯,提升開(kāi)發(fā)效率
- 解決方法數(shù)超過(guò)
65535
的問(wèn)題
想象一下,你的應(yīng)用擁有 Native 應(yīng)用一般極高的性能,又能獲取諸如 Web 應(yīng)用一樣的收益。
嗯,理想很美好不是嘛?
1.3 與組件化的區(qū)別
- 組件化:是將一個(gè)
App
分成多個(gè)模塊,每個(gè)模塊都是一個(gè)組件(module
),開(kāi)發(fā)過(guò)程中可以讓這些組件相互依賴(lài)或獨(dú)立編譯、調(diào)試部分組件,但是這些組件最終會(huì)合并成一個(gè)完整的Apk去發(fā)布到應(yīng)用市場(chǎng)。 - 插件化:是將整個(gè)App拆分成很多模塊,每個(gè)模塊都是一個(gè)Apk(組件化的每個(gè)模塊是一個(gè)lib),最終打包的時(shí)候?qū)⑺拗鰽pk和插件Apk分開(kāi)打包,只需發(fā)布宿主Apk到應(yīng)用市場(chǎng),插件Apk通過(guò)動(dòng)態(tài)按需下發(fā)到宿主Apk。
二、插件化的技術(shù)難點(diǎn)
想讓插件的Apk真正運(yùn)行起來(lái),首先要先能找到插件Apk的存放位置,然后我們要能解析加載Apk里面的代碼。
但是光能執(zhí)行Java代碼是沒(méi)有意義的,在Android系統(tǒng)中有四大組件是需要在系統(tǒng)中注冊(cè)的,具體來(lái)說(shuō)是在 Android 系統(tǒng)的 ActivityManagerService
(AMS) 和 PackageManagerService
(PMS) 中注冊(cè)的,而四大組件的解析和啟動(dòng)都需要依賴(lài) AMS 和 PMS,如何欺騙系統(tǒng),讓他承認(rèn)一個(gè)未安裝的 Apk 中的組件,如何讓宿主動(dòng)態(tài)加載執(zhí)行插件Apk中 Android 組件(即 Activity
、Service
、BroadcastReceiver
、ContentProvider
、Fragment
)等是插件化最大的難點(diǎn)。
另外,應(yīng)用資源引用(特指 R 中引用的資源,如 layout、values 等)也是一大問(wèn)題,想象一下你在宿主進(jìn)程中使用反射加載了一個(gè)插件 Apk,代碼中的 R 對(duì)應(yīng)的 id 卻無(wú)法引用到正確的資源,會(huì)產(chǎn)生什么后果。
總結(jié)一下,其實(shí)做到插件化的要點(diǎn)就這幾個(gè):
- 如何加載并執(zhí)行插件 Apk 中的代碼(
ClassLoader Injection
) - 讓系統(tǒng)能調(diào)用插件 Apk 中的組件(
Runtime Container
) - 正確識(shí)別插件 Apk 中的資源(
Resource Injection
)
當(dāng)然還有其他一些小問(wèn)題,但可能不是所有場(chǎng)景下都會(huì)遇到,我們后面再單獨(dú)說(shuō)。
三、ClassLoader Injection
ClassLoader
是插件化中必須要掌握的,因?yàn)槲覀冎?code>Android 應(yīng)用本身是基于魔改的 Java 虛擬機(jī)的,而由于插件是未安裝的 apk,系統(tǒng)不會(huì)處理其中的類(lèi),所以需要使用 ClassLoader
加載 Apk,然后反射里面的代碼。
3.1 java 中的 ClassLoader
BootstrapClassLoader
負(fù)責(zé)加載 JVM 運(yùn)行時(shí)的核心類(lèi),比如 JAVA_HOME/lib/rt.jar 等等ExtensionClassLoader
負(fù)責(zé)加載 JVM 的擴(kuò)展類(lèi),比如 JAVA_HOME/lib/ext 下面的 jar 包AppClassLoader
負(fù)責(zé)加載classpath
里的 jar 包和目錄
3.2 android 中的 ClassLoader
在Android
系統(tǒng)中ClassLoader
是用來(lái)加載dex文件的,有包含 dex 的 apk 文件以及 jar 文件,dex 文件是一種對(duì)class文件優(yōu)化的產(chǎn)物,在Android
中應(yīng)用打包時(shí)會(huì)把所有class文件進(jìn)行合并、優(yōu)化(把不同的class文件重復(fù)的東西只保留一份),然后生成一個(gè)最終的class.dex
文件
PathClassLoader
用來(lái)加載系統(tǒng)類(lèi)和應(yīng)用程序類(lèi),可以加載已經(jīng)安裝的 apk 目錄下的 dex 文件
public class PathClassLoader extends BaseDexClassLoader { public PathClassLoader(String dexPath, ClassLoader parent) { super(dexPath, null, null, parent); } public PathClassLoader(String dexPath, String libraryPath, ClassLoader parent) { super(dexPath, null, libraryPath, parent); } }
DexClassLoader
用來(lái)加載 dex 文件,可以從存儲(chǔ)空間加載 dex 文件。
public class DexClassLoader extends BaseDexClassLoader { public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) { super(dexPath, new File(optimizedDirectory), libraryPath, parent); } }
我們?cè)诓寮幸话闶褂玫氖?DexClassLoader
。
3.3 雙親委派機(jī)制
每一個(gè) ClassLoader
中都有一個(gè) parent 對(duì)象,代表的是父類(lèi)加載器,在加載一個(gè)類(lèi)的時(shí)候,會(huì)先使用父類(lèi)加載器去加載,如果在父類(lèi)加載器中沒(méi)有找到,自己再進(jìn)行加載,如果 parent 為空,那么就用系統(tǒng)類(lèi)加載器來(lái)加載。通過(guò)這樣的機(jī)制可以保證系統(tǒng)類(lèi)都是由系統(tǒng)類(lèi)加載器加載的。 下面是 ClassLoader
的 loadClass
方法的具體實(shí)現(xiàn)。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { try { if (parent != null) { // 先從父類(lèi)加載器中進(jìn)行加載 c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // 沒(méi)有找到,再自己加載 c = findClass(name); } } return c; }
3.4 如何加載插件中的類(lèi)
要加載插件中的類(lèi),我們首先要?jiǎng)?chuàng)建一個(gè) DexClassLoader
,先看下 DexClassLoader
的構(gòu)造函數(shù)需要哪些參數(shù)。
public class DexClassLoader extends BaseDexClassLoader { public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) { // ... } }
構(gòu)造函數(shù)需要四個(gè)參數(shù): dexPath
是需要加載的 dex / apk / jar 文件路徑 optimizedDirectory
是 dex 優(yōu)化后存放的位置,在 ART 上,會(huì)執(zhí)行 oat 對(duì) dex 進(jìn)行優(yōu)化,生成機(jī)器碼,這里就是存放優(yōu)化后的 odex 文件的位置 librarySearchPath
是 native 依賴(lài)的位置 parent
就是父類(lèi)加載器,默認(rèn)會(huì)先從 parent
加載對(duì)應(yīng)的類(lèi)
創(chuàng)建出 DexClassLaoder
實(shí)例以后,只要調(diào)用其 loadClass(className)
方法就可以加載插件中的類(lèi)了。具體的實(shí)現(xiàn)在下面:
// 從 assets 中拿出插件 apk 放到內(nèi)部存儲(chǔ)空間 private fun extractPlugin() { var inputStream = assets.open("plugin.apk") File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes()) } private fun init() { extractPlugin() pluginPath = File(filesDir.absolutePath, "plugin.apk").absolutePath nativeLibDir = File(filesDir, "pluginlib").absolutePath dexOutPath = File(filesDir, "dexout").absolutePath // 生成 DexClassLoader 用來(lái)加載插件類(lèi) pluginClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader) }
3.5 執(zhí)行插件類(lèi)的方法
通過(guò)反射來(lái)執(zhí)行類(lèi)的方法
val loadClass = pluginClassLoader.loadClass(activityName) loadClass.getMethod("test",null).invoke(loadClass)
我們稱(chēng)這個(gè)過(guò)程叫做 ClassLoader
注入。完成注入后,所有來(lái)自宿主的類(lèi)使用宿主的 ClassLoader
進(jìn)行加載,所有來(lái)自插件 Apk 的類(lèi)使用插件 ClassLoader
進(jìn)行加載,而由于 ClassLoader
的雙親委派機(jī)制,實(shí)際上系統(tǒng)類(lèi)會(huì)不受 ClassLoader 的類(lèi)隔離機(jī)制所影響,這樣宿主 Apk 就可以在宿主進(jìn)程中使用來(lái)自于插件的組件類(lèi)了。
四、Runtime Container
我們之前說(shuō)到 Activity
插件化最大的難點(diǎn)是如何欺騙系統(tǒng),讓他承認(rèn)一個(gè)未安裝的 Apk 中的組件。 因?yàn)椴寮莿?dòng)態(tài)加載的,所以插件的四大組件不可能注冊(cè)到宿主的 Manifest
文件中,而沒(méi)有在 Manifest 中注冊(cè)的四大組件是不能和系統(tǒng)直接進(jìn)行交互的。 如果直接把插件的 Activity 注冊(cè)到宿主 Manifest
里就失去了插件化的動(dòng)態(tài)特性,因?yàn)槊看尾寮行略?Activity
都要修改宿主 Manifest
并且重新打包,那就和直接寫(xiě)在宿主中沒(méi)什么區(qū)別了。
4.1 為什么沒(méi)有注冊(cè)的 Activity 不能和系統(tǒng)交互
這里的不能直接交互的含義有兩個(gè)
- 系統(tǒng)會(huì)檢測(cè)
Activity
是否注冊(cè) 如果我們啟動(dòng)一個(gè)沒(méi)有在Manifest
中注冊(cè)的 Activity,會(huì)發(fā)現(xiàn)報(bào)如下 error:
android.content.ActivityNotFoundException: Unable to find explicit activity class {com.zyg.commontec/com.zyg.plugin.PluginActivity}; have you declared this activity in your AndroidManifest.xml?
這個(gè) log 在 Instrumentation
的 checkStartActivityResult
方法中可以看到:
public class Instrumentation { public static void checkStartActivityResult(int res, Object intent) { if (!ActivityManager.isStartResultFatalError(res)) { return; } switch (res) { case ActivityManager.START_INTENT_NOT_RESOLVED: case ActivityManager.START_CLASS_NOT_FOUND: if (intent instanceof Intent && ((Intent)intent).getComponent() != null) throw new ActivityNotFoundException( "Unable to find explicit activity class " + ((Intent)intent).getComponent().toShortString() + "; have you declared this activity in your AndroidManifest.xml?"); throw new ActivityNotFoundException( "No Activity found to handle " + intent); ... } } }
- Activity 的生命周期無(wú)法被調(diào)用,其實(shí)一個(gè) Activity 主要的工作,都是在其生命周期方法中調(diào)用了,既然上一步系統(tǒng)檢測(cè)了
Manifest
注冊(cè)文件,啟動(dòng)Activity
被拒絕,那么其生命周期方法也肯定不會(huì)被調(diào)用了。從而插件 Activity 也就不能正常運(yùn)行了。
4.2 運(yùn)行時(shí)容器技術(shù)
由于Android
中的組件(Activity
,Service
,BroadcastReceiver
和ContentProvider
)是由系統(tǒng)創(chuàng)建的,并且由系統(tǒng)管理生命周期。 僅僅構(gòu)造出這些類(lèi)的實(shí)例是沒(méi)用的,還需要管理組件的生命周期。其中以Activity最為復(fù)雜,不同框架采用的方法也不盡相同。插件化如何支持組件生命周期的管理。 大致分為兩種方式:
- 運(yùn)行時(shí)容器技術(shù)(
ProxyActivity
代理) - 預(yù)埋
StubActivity
,hook系統(tǒng)啟動(dòng)Activity的過(guò)程
我們的解決方案很簡(jiǎn)單,即運(yùn)行時(shí)容器技術(shù),簡(jiǎn)單來(lái)說(shuō)就是在宿主 Apk 中預(yù)埋一些空的 Android 組件,以 Activity 為例,我預(yù)置一個(gè) ContainerActivity extends Activity
在宿主中,并且在 AndroidManifest
.xml 中注冊(cè)它。
它要做的事情很簡(jiǎn)單,就是幫助我們作為插件 Activity 的容器,它從 Intent 接受幾個(gè)參數(shù),分別是插件的不同信息,如:
pluginName
pluginApkPath
pluginActivityName
等,其實(shí)最重要的就是 pluginApkPath
和 pluginActivityName
,當(dāng) ContainerActivity
啟動(dòng)時(shí),我們就加載插件的 ClassLoader
、Resource
,并反射 pluginActivityName
對(duì)應(yīng)的 Activity 類(lèi)。當(dāng)完成加載后,ContainerActivity
要做兩件事:
- 轉(zhuǎn)發(fā)所有來(lái)自系統(tǒng)的生命周期回調(diào)至插件
Activity
- 接受
Activity
方法的系統(tǒng)調(diào)用,并轉(zhuǎn)發(fā)回系統(tǒng)
我們可以通過(guò)復(fù)寫(xiě) ContainerActivity
的生命周期方法來(lái)完成第一步,而第二步我們需要定義一個(gè) PluginActivity
,然后在編寫(xiě)插件 Apk 中的 Activity 組件時(shí),不再讓其集成 android.app.Activity
,而是集成自我們的 PluginActivity
。
public class ContainerActivity extends Activity { private PluginActivity pluginActivity; @Override protected void onCreate(Bundle savedInstanceState) { String pluginActivityName = getIntent().getString("pluginActivityName", ""); pluginActivity = PluginLoader.loadActivity(pluginActivityName, this); if (pluginActivity == null) { super.onCreate(savedInstanceState); return; } pluginActivity.onCreate(); } @Override protected void onResume() { if (pluginActivity == null) { super.onResume(); return; } pluginActivity.onResume(); } @Override protected void onPause() { if (pluginActivity == null) { super.onPause(); return; } pluginActivity.onPause(); } // ... }
public class PluginActivity { private ContainerActivity containerActivity; public PluginActivity(ContainerActivity containerActivity) { this.containerActivity = containerActivity; } @Override public <T extends View> T findViewById(int id) { return containerActivity.findViewById(id); } // ... } // 插件 `Apk` 中真正寫(xiě)的組件 public class TestActivity extends PluginActivity { // ...... }
是不是感覺(jué)有點(diǎn)看懂了,雖然真正搞的時(shí)候還有很多小坑,但大概原理就是這么簡(jiǎn)單,啟動(dòng)插件組件需要依賴(lài)容器,容器負(fù)責(zé)加載插件組件并且完成雙向轉(zhuǎn)發(fā),轉(zhuǎn)發(fā)來(lái)自系統(tǒng)的生命周期回調(diào)至插件組件,同時(shí)轉(zhuǎn)發(fā)來(lái)自插件組件的系統(tǒng)調(diào)用至系統(tǒng)。
4.3 字節(jié)碼替換
該方式雖然能夠很好的實(shí)現(xiàn)啟動(dòng)插件Activity
的目的,但是由于開(kāi)發(fā)式侵入性很強(qiáng),插件中的Activity
必須繼承PluginActivity
,如果想把之前的模塊改造成插件需要很多額外的工作。
class TestActivity extends Activity {} -> class TestActivity extends PluginActivity {}
有沒(méi)有什么辦法能讓插件組件的編寫(xiě)與原來(lái)沒(méi)有任何差別呢?
Shadow
的做法是字節(jié)碼替換插件,這是一個(gè)非常棒的想法,簡(jiǎn)單來(lái)說(shuō),Android
提供了一些 Gradle 插件開(kāi)發(fā)套件,其中有一項(xiàng)功能叫 Transform Api
,它可以介入項(xiàng)目的構(gòu)建過(guò)程,在字節(jié)碼生成后、dex 文件生成前,對(duì)代碼進(jìn)行某些變換,具體怎么做的不說(shuō)了,可以自己看文檔。
實(shí)現(xiàn)的功能嘛,就是用戶(hù)配置 Gradle 插件后,正常開(kāi)發(fā),依然編寫(xiě):
class TestActivity extends Activity {}
然后完成編譯后,最后的字節(jié)碼中,顯示的卻是:
class TestActivity extends PluginActivity {}
到這里基本的框架就差不多結(jié)束了。
五、Resource Injection
最后要說(shuō)的是資源注入,其實(shí)這一點(diǎn)相當(dāng)重要,Android
應(yīng)用的開(kāi)發(fā)其實(shí)崇尚的是邏輯與資源分離的理念,所有資源(layout
、values
等)都會(huì)被打包到 Apk 中,然后生成一個(gè)對(duì)應(yīng)的 R 類(lèi),其中包含對(duì)所有資源的引用 id。
資源的注入并不容易,好在 Android
系統(tǒng)給我們留了一條后路,最重要的是這兩個(gè)接口:
PackageManager#getPackageArchiveInfo
:根據(jù) Apk 路徑解析一個(gè)未安裝的 Apk 的 PackageInfo
PackageManager#getResourcesForApplication
:根據(jù) ApplicationInfo
創(chuàng)建一個(gè) Resources
實(shí)例
我們要做的就是在上面 ContainerActivity#onCreate
中加載插件 Apk 的時(shí)候,用這兩個(gè)方法創(chuàng)建出來(lái)一份插件資源實(shí)例。具體來(lái)說(shuō)就是先用 PackageManager#getPackageArchiveInfo
拿到插件 Apk 的 PackageInfo
,有了 PacakgeInfo 之后我們就可以自己組裝一份 ApplicationInfo
,然后通過(guò) PackageManager#getResourcesForApplication
來(lái)創(chuàng)建資源實(shí)例,大概代碼像這樣:
PackageManager packageManager = getPackageManager(); PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo( pluginApkPath, PackageManager.GET_ACTIVITIES | PackageManager.GET_META_DATA | PackageManager.GET_SERVICES | PackageManager.GET_PROVIDERS | PackageManager.GET_SIGNATURES ); packageArchiveInfo.applicationInfo.sourceDir = pluginApkPath; packageArchiveInfo.applicationInfo.publicSourceDir = pluginApkPath; Resources injectResources = null; try { injectResources = packageManager.getResourcesForApplication(packageArchiveInfo.applicationInfo); } catch (PackageManager.NameNotFoundException e) { // ... }
拿到資源實(shí)例后,我們需要將宿主的資源和插件資源 Merge 一下,編寫(xiě)一個(gè)新的 Resources
類(lèi),用這樣的方式完成自動(dòng)代理:
public class PluginResources extends Resources { private Resources hostResources; private Resources injectResources; public PluginResources(Resources hostResources, Resources injectResources) { super(injectResources.getAssets(), injectResources.getDisplayMetrics(), injectResources.getConfiguration()); this.hostResources = hostResources; this.injectResources = injectResources; } @Override public String getString(int id, Object... formatArgs) throws NotFoundException { try { return injectResources.getString(id, formatArgs); } catch (NotFoundException e) { return hostResources.getString(id, formatArgs); } } // ... }
然后我們?cè)?ContainerActivity
完成插件組件加載后,創(chuàng)建一份 Merge
資源,再?gòu)?fù)寫(xiě) ContainerActivity#getResources
,將獲取到的資源替換掉:
public class ContainerActivity extends Activity { private Resources pluginResources; @Override protected void onCreate(Bundle savedInstanceState) { // ... pluginResources = new PluginResources(super.getResources(), PluginLoader.getResources(pluginApkPath)); // ... } @Override public Resources getResources() { if (pluginActivity == null) { return super.getResources(); } return pluginResources; } }
這樣就完成了資源的注入。
到此這篇關(guān)于淺談Android
插件化的文章就介紹到這了,更多相關(guān)Android
插件化內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- Android打包上傳AAR文件到Maven倉(cāng)庫(kù)的示例
- Android Studio新建工程默認(rèn)在build.gradle中加入maven阿里源的問(wèn)題
- 解決android studio 3.0 加載項(xiàng)目過(guò)慢問(wèn)題--maven倉(cāng)庫(kù)選擇
- android 上傳aar到私有maven服務(wù)器的示例
- Android?Gradle?插件自定義Plugin實(shí)現(xiàn)注意事項(xiàng)
- Android?Studio?中Gradle配置sonarqube插件(推薦)
- 基于IntelliJ IDEA/Android Studio插件開(kāi)發(fā)指南(推薦)
- Android?使用maven?publish插件發(fā)布產(chǎn)物(aar)流程實(shí)踐
相關(guān)文章
Android使用SharedPreferences存儲(chǔ)XML文件的實(shí)現(xiàn)方法
這篇文章主要介紹了Android使用SharedPreferences存儲(chǔ)XML文件的實(shí)現(xiàn)方法,實(shí)例分析了SharedPreferences類(lèi)的基本初始化與文件存儲(chǔ)相關(guān)技巧,需要的朋友可以參考下2016-07-07Android 自定義TextView去除paddingTop和paddingBottom
這篇文章主要介紹了Android 自定義TextView去除paddingTop和paddingBottom的相關(guān)資料,這里提供實(shí)例來(lái)幫助大家實(shí)現(xiàn)這樣的功能,需要的朋友可以參考下2017-09-09Android中DialogFragment自定義背景與寬高的方法
DialogFragment 彈出框默認(rèn)是在屏幕的中央,左右還有留白,那么如何自定義背景和寬高呢?下面這篇文章就來(lái)給大家介紹了關(guān)于Android中DialogFragment自定義背景與寬高的方法,需要的朋友可以參考下。2017-08-08Android中Item實(shí)現(xiàn)點(diǎn)擊水波紋效果
這篇文章主要給大家介紹了關(guān)于Android中Item實(shí)現(xiàn)點(diǎn)擊水波紋效果的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)各位Android開(kāi)發(fā)者們具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2018-11-11Android 中build.prop 文件與 getprop 命令
這篇文章主要介紹了Android 中build.prop 文件與 getprop 命令的相關(guān)資料,需要的朋友可以參考下2017-06-06android利用handler實(shí)現(xiàn)倒計(jì)時(shí)功能
這篇文章主要為大家詳細(xì)介紹了android利用handler實(shí)現(xiàn)倒計(jì)時(shí)功能,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-11-11Android性能優(yōu)化之RecyclerView分頁(yè)加載組件功能詳解
這篇文章主要為大家介紹了Android性能優(yōu)化之RecyclerView分頁(yè)加載組件功能詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-09-09