編輯:關於Android編程
今年真是熱補丁框架的洪荒之力爆發的一年,短短幾個月內,已經出現了好幾個熱修復的框架了,基本上都是大同小異,這裡我就不過多的去評論這些框架。只有自己真正的去經歷過,你才會發現其中的大寫的坑
事實上,現在出現的大多數熱修復的框架,穩定性和兼容性都還達不到要求,包括阿裡的Andfix,據同事說,我們自己的app原本沒有多少crash,接入了andfix倒引起了一部分的crash,這不是一個熱修復框架所應該具有的“變態功能”。雖然阿裡百川現在在大力推廣這套框架,我依舊不看好,只是其思路還是有學習價值的。
Dex的熱修復目前來看基本上有四種方案:
阿裡系的從native層入手,見AndFix QQ空間的方案,插樁,見安卓App熱補丁動態修復技術介紹 微信的方案,見微信Android熱補丁實踐演進之路,dexDiff和dexPatch,方法很牛逼,需要全量插入,但是這個全量插入的dex中需要刪除一些過早加載的類,不然同樣會報class is pre verified異常,還有一個缺點就是合成占內存和內置存儲空間。微信讀書的方式和微信類似,見Android Patch 方案與持續交付,不過微信讀書是miniloader方式,啟動時容易ANR,在我錘子手機上變現出來特別明顯,長時間的卡圖標現象。 美團的方案,也就是instant run的方案,見Android熱更新方案Robust此外,微信的方案是多classloader,這種方式可以解決用multidex方式在部分機型上不生效patch的問題,同時還帶來一個好處,這種多classloader的方式使用的是instant run的代碼,如果存在native library的修復,也會帶來極大的方便。
而native libraray的修復,目前來說,基本上有兩種方案。。
類似multidex的dex方式,插入目錄到數組最前面,具體文章見Android熱更新之so庫的熱更新,需要處理系統的兼容性問題,系統分隔線是Android 6.0 第二種方式需要依賴多classloader,在構造BaseDexClassLoader的時候,獲取原classloader的native library,通過環境變量分隔符(冒號),將patch的native library與原目錄進行連接,patch目錄在前,這樣同樣可以達到修復的目的,缺點是需要依賴dex的熱修復,優點是應用native library時不需要處理兼容性問題,當然從patch中釋放出來的時候也需要處理兼容性問題。第二種方式的實現可以看看BaseDexClassLoader的構造函數
BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent)
只需要在修復dex的同時,如果有native library,則獲取原來的路徑與patch的路徑進行連接,偽代碼如下:
nativeLibraryPath = 獲取與原始路徑; nativeLibraryPath = patchNativeLibraryPath + File.pathSeparator + nativeLibraryPath; IncrementalClassLoader inject = IncrementalClassLoader.inject( classLoader, nativeLibraryPath, optDir.getAbsolutePath(), dexList);
而這種方式需要強依賴dex的修復,如果沒有dex,就無能為例了,實際情況基本上是兩種方式交叉使用,在沒有dex的情況下,使用另外一種方式。
而native library還有一個坑,就是從patch中釋放so的過程,這個過程需要處理兼容性,在android 21以下,通過下面這個函數去釋放
com.android.internal.content.NativeLibraryHelper.copyNativeBinariesIfNeededLI
而在andrdod 21及以上,則通過下面的這幾個函數去釋放
com.android.internal.content.NativeLibraryHelper$Handle.create() com.android.internal.content.NativeLibraryHelper.findSupportedAbi() com.android.internal.content.NativeLibraryHelper.copyNativeBinaries()
而對於資源的熱修復,其實主要還是和插件化的思路是一樣的,具體實現可以參考兩個
Atlas或者攜程的插件化框架 Instant run的資源處理方式,甚至可以做到運行期立即生效。本篇文章就來說說資源的熱修復的實現思路,在這之前,需要貼兩個鏈接,以下文章的內容基於這兩個鏈接去實現,所以務必先看看,不然會一臉懵逼。一個是instant run的源碼,自備梯子,另一個是馮老師寫的一個類,這個類在Atlas中出現過,後來被馮老師重寫了,同樣自備梯子。
instant-run源碼 Hack.java實現重要的事情說三遍
自備梯子
自備梯子
自備梯子
資源的熱修復實現,主要由一下幾個步驟組成:
提前感知系統兼容性,不兼容則不進行後續操作 服務器端生成patch的資源,客戶端應用patch的資源 替換系統AssetManger,加入patch的資源對於第一步,我們需要先看看instant run對於資源部分的實現,其偽代碼如下
AssetManager newAssetManager = new AssetManager(); newAssetManager.addAssetPath(externalResourceFile) // Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm // in L, so we do it unconditionally. newAssetManager.ensureStringBlocks(); // Find the singleton instance of ResourcesManager ResourcesManager resourcesManager = ResourcesManager.getInstance(); // Iterate over all known Resources objects if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { for (WeakReferencewr : resourcesManager.mActiveResources.values()) { Resources resources = wr.get(); // Set the AssetManager of the Resources instance to our brand new one resources.mAssets = newAssetManager; resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics()); } }
代碼很簡單,通過調用addAssetPath將patch的資源加到新建的AssetManager對象中,然後將內存中所有Resources對象中的AssetManager對象替換為新建的AssetManager對象。當然還需要處理兼容性問題,對於兼容性問題,則需要用到馮老師的Hack類(這裡我為了與原來馮老師沒有重寫前的Hack類做區分,將其重命名了HackPlus,意思你懂的),具體Hack過程請參考Atlas或者攜程的插件化框架的實現,然後基於instant run進行實現,當然這種方式有一部分資源是修復不了了,比如notification。
主要的分界線是Android 19 和 Android N坑麼,你沒遇到,總是說沒有,遇到了,坑無數。
首先需要拿到App運行後內存中的Resources對象
Android N,通過ResourcesManager中的mResourceReferences去獲取Resources對象,是個ArrayList對象 Android 19到Android N(不含N),通過ResourcesManager中的mActiveResources去獲取Resources對象,是個ArrayMap對象 Android 19以下,通過ActivityThread的mActiveResources去獲取Resources對象,是個HashMap對象。 接著就是替換Resources中的AssetManager對象這裡我已經基本實現了反射檢測系統支持性相關的代碼,主要就是對以上分析的內容做反射檢測,一旦發生異常,則不再進行資源的修復,代碼如下(HackPlus的源碼見上面的Hack.java的源碼):
mAssertionErr; public AssertionArrayException(String str) { super(str); this.mAssertionErr = new ArrayList(); } public void addException(AssertionException hackAssertionException) { this.mAssertionErr.add(hackAssertionException); } public void addException(List list) { this.mAssertionErr.addAll(list); } public List getExceptions() { return this.mAssertionErr; } public static AssertionArrayException mergeException(AssertionArrayException assertionArrayException, AssertionArrayException assertionArrayException2) { if (assertionArrayException == null) { return assertionArrayException2; } if (assertionArrayException2 == null) { return assertionArrayException; } AssertionArrayException assertionArrayException3 = new AssertionArrayException(assertionArrayException.getMessage() + ";" + assertionArrayException2.getMessage()); assertionArrayException3.addException(assertionArrayException.getExceptions()); assertionArrayException3.addException(assertionArrayException2.getExceptions()); return assertionArrayException3; } public String toString() { StringBuilder stringBuilder = new StringBuilder(); for (AssertionException hackAssertionException : this.mAssertionErr) { stringBuilder.append(hackAssertionException.toString()).append(";"); try { if (hackAssertionException.getCause() instanceof NoSuchFieldException) { Field[] declaredFields = hackAssertionException.getHackedClass().getDeclaredFields(); stringBuilder.append(hackAssertionException.getHackedClass().getName()).append(".").append(hackAssertionException.getHackedFieldName()).append(";"); for (Field field : declaredFields) { stringBuilder.append(field.getName()).append(File.separator); } } else if (hackAssertionException.getCause() instanceof NoSuchMethodException) { Method[] declaredMethods = hackAssertionException.getHackedClass().getDeclaredMethods(); stringBuilder.append(hackAssertionException.getHackedClass().getName()).append("->").append(hackAssertionException.getHackedMethodName()).append(";"); for (int i = 0; i < declaredMethods.length; i++) { if (hackAssertionException.getHackedMethodName().equals(declaredMethods[i].getName())) { stringBuilder.append(declaredMethods[i].toGenericString()).append(File.separator); } } } } catch (Exception e) { e.printStackTrace(); } stringBuilder.append("@@@@"); } return stringBuilder.toString(); } } " data-snippet-id="ext.27e0015bab58b1c74cae9139390abd6d" data-snippet-saved="false" data-codota-status="done">//這個類用於保存hack過程中發生的異常,一旦mAssertionErr不為空,則表示當前系統不支持資源的熱修復,直接return,不進行修復 public class AssertionArrayException extends Exception { private static final long serialVersionUID = 1; private List mAssertionErr; public AssertionArrayException(String str) { super(str); this.mAssertionErr = new ArrayList(); } public void addException(AssertionException hackAssertionException) { this.mAssertionErr.add(hackAssertionException); } public void addException(List list) { this.mAssertionErr.addAll(list); } public List getExceptions() { return this.mAssertionErr; } public static AssertionArrayException mergeException(AssertionArrayException assertionArrayException, AssertionArrayException assertionArrayException2) { if (assertionArrayException == null) { return assertionArrayException2; } if (assertionArrayException2 == null) { return assertionArrayException; } AssertionArrayException assertionArrayException3 = new AssertionArrayException(assertionArrayException.getMessage() + ";" + assertionArrayException2.getMessage()); assertionArrayException3.addException(assertionArrayException.getExceptions()); assertionArrayException3.addException(assertionArrayException2.getExceptions()); return assertionArrayException3; } public String toString() { StringBuilder stringBuilder = new StringBuilder(); for (AssertionException hackAssertionException : this.mAssertionErr) { stringBuilder.append(hackAssertionException.toString()).append(";"); try { if (hackAssertionException.getCause() instanceof NoSuchFieldException) { Field[] declaredFields = hackAssertionException.getHackedClass().getDeclaredFields(); stringBuilder.append(hackAssertionException.getHackedClass().getName()).append(".").append(hackAssertionException.getHackedFieldName()).append(";"); for (Field field : declaredFields) { stringBuilder.append(field.getName()).append(File.separator); } } else if (hackAssertionException.getCause() instanceof NoSuchMethodException) { Method[] declaredMethods = hackAssertionException.getHackedClass().getDeclaredMethods(); stringBuilder.append(hackAssertionException.getHackedClass().getName()).append("->").append(hackAssertionException.getHackedMethodName()).append(";"); for (int i = 0; i < declaredMethods.length; i++) { if (hackAssertionException.getHackedMethodName().equals(declaredMethods[i].getName())) { stringBuilder.append(declaredMethods[i].toGenericString()).append(File.separator); } } } } catch (Exception e) { e.printStackTrace(); } stringBuilder.append("@@@@"); } return stringBuilder.toString(); } }
//具體Hack類,主要Hack AssetManager相關類, public class AndroidHack { private static final String TAG = "AndroidHack"; //exception public static AssertionArrayException exceptionArray; //resources public static HackPlus.HackedClass AssetManager; public static HackedMethod0 AssetManager_construct; public static HackPlus.HackedMethod1AssetManager_addAssetPath; public static HackedMethod0 AssetManager_ensureStringBlocks; //>=19 public static HackedClass
使用的時候,只要在加載patch資源前,調用如下方法進行檢測
if(!AndroidHack.defineAndVerify()){ //不加載patch資源 return; } //加載patch資源邏輯
patch資源的生成比較麻煩,我們放在最後面說明,現在假設我們有一個包含整個apk的資源的文件,需要運行時替換,現在來實現上面的加載patch資源的邏輯,具體邏輯上面反射的時候已經說明了,這時候只需要調用上面反射獲取的包裝類,進行替換即可,直接看代碼中的注釋:
public class ResourceLoader { private static final String TAG = "ResourceLoader"; public static boolean patchResources(Context context, File patchResource) { try { if (context == null || patchResource == null){ return false; } if (!patchResource.exists()) { return false; } //通過構造函數new一個AssetManager對象 AssetManager newAssetManager = AndroidHack.AssetManager_construct.invoke().statically(); //調用AssetManager對象的addAssetPath方法添加patch資源 int cookie = AndroidHack.AssetManager_addAssetPath.invokeWithParam(patchResource.getAbsolutePath()).on(newAssetManager); //添加成功時cookie必然大於0 if (cookie == 0) { Logger.e(TAG, "Could not create new AssetManager"); return false; } // 在Android 19以前需要調用這個方法,但是Android L後不需要,實際情況Andorid L上調用也不會有問題,因此這裡不區分版本 // Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm // in L, so we do it unconditionally. AndroidHack.AssetManager_ensureStringBlocks.invoke().on(newAssetManager); //獲取內存中的Resource對象的弱引用 Collection> references; if (Build.VERSION.SDK_INT >= 24) { // Android N,獲取的是一個ArrayList,直接賦值給references對象 // Find the singleton instance of ResourcesManager Object resourcesManager = AndroidHack.ResourcesManager_getInstance.invoke().statically(); //noinspection unchecked references = (Collection >) AndroidHack.ResourcesManager_mResourceReferences.on(resourcesManager).get(); } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { //Android 19以上 獲得的是一個ArrayMap,調用其values方法後賦值給references // Find the singleton instance of ResourcesManager Object resourcesManager = AndroidHack.ResourcesManager_getInstance.invoke().statically(); @SuppressWarnings("unchecked") ArrayMap> arrayMap = AndroidHack.ResourcesManager_mActiveResources.on(resourcesManager).get(); references = arrayMap.values(); } else { //Android 19以下,通過ActivityThread獲取得到的是一個HashMap對象,通過其values方法獲得對象賦值給references Object activityThread = AndroidHack.getActivityThread(); @SuppressWarnings("unchecked") HashMap> map = (HashMap>) AndroidHack.ActivityThread_mActiveResources.on(activityThread).get(); references = map.values(); } //遍歷獲取到的Ressources對象的弱引用,將其AssetManager對象替換為我們的patch的AssetManager for (WeakReference wr : references) { Resources resources = wr.get(); // Set the AssetManager of the Resources instance to our brand new one if (resources != null) { if (Build.VERSION.SDK_INT >= 24) { Object resourceImpl = AndroidHack.Resources_ResourcesImpl.get(resources); AndroidHack.ResourcesImpl_mAssets.set(resourceImpl, newAssetManager); } else { AndroidHack.Resources_mAssets.set(resources, newAssetManager); } resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics()); } } return true; } catch (Throwable throwable) { Logger.e(TAG, throwable); throwable.printStackTrace(); } return false; } }
這樣一來,就在Appliction啟動的時候完成了資源的熱修復,當然我們也可以像instant run那樣,把activity也處理,不過我們簡單起見,讓其重啟生效,所以activity就不處理了。
於是,我們Appliction的onCreate()中的代碼就變成了下面這個樣子
if (hasResourcePatch){ if (!AndroidHack.defineAndVerify()) { //不加載patch資源 return; } //加載patch資源邏輯 File file = new File("/path/to/patchResource.apk"); ResourceLoader.patchResources(this, file); }
這裡有一個坑。
patch應用成功後,如果要刪除patch,patch文件的刪除一定要謹慎,最好先通過配置文件標記patch不可用,下次啟動時檢測該標記,然後再刪除,運行期刪除正在使用的patch文件會導致所有進程的重啟,Application中的所有邏輯會被初始化一次。
還差最後一步,patch的資源從哪裡來,這裡主要講兩種方式。
直接下發整個apk文件,全量的資源,想怎麼用就怎麼用,當然缺點很明顯,文件太大了,下載容易出錯,不過也最簡單。 下發patch部分的資源,在客戶端和沒改變的資源合成新的apk,這種方式的優點是文件小,缺點是合成時占內存,需要開啟多進程去合成,比較復雜,沒有辦法校驗合成文件的md5值。無論哪一種方式,都需要public.xml去固定資源id。
這裡討論的是第二種方式,所以給出精簡版的實現思路:
首先需要生成public.xml,public.xml的生成通過aapt編譯時添加-P參數生成。相關代碼通過gradle插件去hook Task無縫加入該參數,有一點需要注意,通過appt生成的public.xml並不是可以直接用的,該文件中存在id類型的資源,生成patch時應用進去編譯的時候會報resource is not defined,解決方法是將id類型的資源單獨記錄到ids.xml文件中,相當於一個聲明過程,編譯的時候和public.xml一樣,將ids.xml也參與編譯即可。
/** * 添加aapt addition -P選項 */ String processResourcesTaskName = variant.variantData.getScope().getGenerateRClassTask().name ProcessAndroidResources processResourcesTask = (ProcessAndroidResources) project.tasks.getByName(processResourcesTaskName) Closure generatePubicXmlClosure = { if (processResourcesTask) { //添加-P 參數,生成public.xml AaptOptions aaptOptions = processResourcesTask.aaptOptions File outPublicXml = new File(outputDir, PUBLIC_XML) aaptOptions.additionalParameters("-P", outPublicXml.getAbsolutePath()) processResourcesTask.setAaptOptions(aaptOptions) } } /** * public.xml中對一些選項進行剔除,目前處理id類型資源,不然應用的時候編譯不過,會報resource is not defined,主要是生成一個ids.xml,相當於對這部分資源進行聲明 */ Closure handlePubicXmlClosure = { if (processResourcesTask) { File outPublicXml = new File(outputDir, PUBLIC_XML) if (outPublicXml.exists()) { SAXReader reader = new SAXReader(); Document document = reader.read(outPublicXml); Element root = document.getRootElement(); ListchildElements = root.elements(); File idsFile = new File(outPublicXml.getParentFile(), IDS_XML) if (idsFile.exists()) { idsFile.delete() } if (!idsFile.exists()) { idsFile.getParentFile().mkdirs() idsFile.createNewFile() } idsFile.append("") idsFile.append("\n") idsFile.append(" ") idsFile.append("\n") for (Element child : childElements) { String attrName = child.attribute("name").value String attrType = child.attribute("type").value if ("id".equalsIgnoreCase(attrType)) { String value = child.asXML() idsFile.append(" ") } } } if (processResourcesTask) { processResourcesTask.doFirst(generatePubicXmlClosure); processResourcesTask.doLast(handlePubicXmlClosure) }- \n") project.logger.error "write id item ${attrName}" } } idsFile.append("
在編譯資源之前,將public.xml和ids.xml文件拷貝到資源目錄values下,並檢測values.xml文件中是否有已經定義的id類型的資源,如果有,則從ids.xml文件中將其刪除,否則會報resource is already defined的異常,也會編譯不過去。
/** * 應用public.xml */ String mergeResourcesTaskName = variant.variantData.getScope().getMergeResourcesTask().name MergeResources mergeResourcesTask = (MergeResources) project.tasks.getByName(mergeResourcesTaskName) Closure applyPubicXmlClosure = { if (mergeResourcesTask != null) { if (oldTinkerDir != null && needApplyPublicXml) { File publicXmlFile = new File(oldTinkerDir, "${dirName}/${PUBLIC_XML}") if (publicXmlFile.exists()) { File toDir = new File(mergeResourcesTask.outputDir, "values") project.copy { project.logger.error "\n$variant.name:copy a ${PUBLIC_XML} from ${publicXmlFile.getAbsolutePath()} to ${toDir}/${PUBLIC_XML}" from(publicXmlFile.getParentFile()) { include PUBLIC_XML rename PUBLIC_XML, "${PUBLIC_XML}" } into(toDir) } } else { logger.error("${publicXmlFile.absolutePath} does not exist") } File valuesFile = new File(mergeResourcesTask.outputDir, "values/values.xml") File oldIdsFile = new File(oldTinkerDir, "${dirName}/${IDS_XML}") if (valuesFile.exists() && oldIdsFile.exists()) { SAXReader valuesReader = new SAXReader(); Document valuesDocument = valuesReader.read(valuesFile); Element valuesRoot = valuesDocument.getRootElement() ListpublicIds = valuesRoot.selectNodes("item[@type='id']") if (publicIds != null && publicIds.size() != 0) { Set existIdItems = new HashSet (); for (Element element : publicIds) { existIdItems.add(element.attribute("name").value) } logger.error "existIdItems:${existIdItems}" SAXReader oldIdsReader = new SAXReader(); Document oldIdsDocument = oldIdsReader.read(oldIdsFile); Element oldIdsRoot = oldIdsDocument.getRootElement(); List oldElements = oldIdsRoot.elements(); if (oldElements != null && oldElements.size() != 0) { File newIdsFile = new File(mergeResourcesTask.outputDir, "values/${IDS_XML}") newIdsFile.append("") newIdsFile.append("\n") newIdsFile.append(" ") newIdsFile.append("\n") for (Element element : oldElements) { String itemName = element.attribute("name").value if (!existIdItems.contains(itemName)) { newIdsFile.append(" ${element.asXML()}\n") } else { logger.error "already exist id item ${itemName}" } } newIdsFile.append(" ") } } } else { logger.error("${valuesFile.absolutePath} does not exist") } } else { logger.error "res not changed.not to apply public.xml" } } } if (mergeResourcesTask) { mergeResourcesTask.doLast(applyPubicXmlClosure); }
這樣一來,按照正常流程去編譯,生成的apk安裝包就可以獲得了,然後將這個new.apk和有問題的old.apk進行差量算法,這裡只考慮資源相關文件,即assets目錄,res目錄,arsc文件,AndroidManifest.xml文件,相關算法如下:
對比new.apk和old.apk中的所有資源相關的文件。 對於新增資源文件,則直接壓入patch.apk中。 對於刪除的資源文件,則不處理到patch.apk中。 對於改變的資源文件,如果是assets或者res目錄中的資源,則直接壓縮到patch.apk中,如果是arsc文件,則使用bsdiff算法計算其差量文件,壓入patch.apk,文件名不變。 對於改變和新增的文件,通過一個meta文件去記錄其原始文件的adler32和合成後預期文件的adler32值,以及文件名,這是個文本文件,直接壓縮到patch.apk中去。 對patch.apk進行簽名。這樣做的好處是能將資源patch文件盡可能的減小到最低,實際情況嚴重下來,res目錄下的資源文件大小都非常小,沒有必要去進行diff,所以直接使用原文件,而arsc文件則相對比較大,在考慮文件大小和內存的兩個因素下,犧牲內存換大小還是ok的,所以在下發前,我們對其進行diff,生成diff文件,在客戶端進行合成最終的arsc文件。
客戶端下載到patch.apk後需要進行還原,還原的步驟如下:
考慮到客戶端jni的兼容性問題,bspatch算法全部使用java實現 首先校驗patch.apk的簽名 讀取壓縮包中meta文件,判斷哪些文件是新增文件,哪些文件是改變的文件。 遍歷patch.apk中的文件,如果是新增文件,則壓縮到new.apk文件中去 如果是改變的文件,如果是assets和res文件夾下的資源,則直接壓縮到new.apk文件中,如果是arsc文件,則應用bspatch算法合成最終的arsc文件,壓縮到new.apk中 如果文件沒有改變,則直接復制old.apk中的原始文件到new.apk中 以上任何一個步驟都會去校驗合成時舊文件的adler32和合成後的adler32值和meta文件中記錄的是否符合 由於無法驗證合成後的文件的md5值(沒有記錄哪些文件被刪除了,加上壓縮算法等原因),需要使用一種方式在加載前進行驗證,這裡使用crc32值。 合成成功後計算new.apk文件的crc32值,計算方式進行改進,不計算所有文件內容的crc32,為了快速計算,只計算文件的某一個特定段的crc32值,比如文件從200字節開始到2000字節部分的crc32值,並保存在sharePrefrences中,加載patch前進行校驗crc32,校驗不通過,則直接刪除patch文件,當然這種計算方式有一定概率會把錯誤的文件當成正確的,畢竟計算的不是完整的文件,當然正確的文件是一定不會當成錯誤的,這種低概率事件可以接受。這種方式的兼容性如何?簡單自測了下,4.0-7.0的模擬器運行全部通過,當然不排除國產奇葩ROM的兼容性,所以這裡我不宣稱100%兼容。
無圖言屌,沒圖你說個jb,先上一張沒有進行熱修復的圖:
熱修復之後的效果圖
最後送上一句話:
想自己做個apk,還在為素材而苦惱嗎?看到優秀的apk設計,還在為怎麼看到別人的實現代碼而苦惱嗎?看著AndroidStudio 多渠道打包那麼爽,而自己坑爹的還在用Ec
本文為大家分享Android登陸界面實現清除輸入框內容和震動效果的全部代碼,具體內容如下:效果圖:主要代碼如下自定義的一個EditText,用於實現有文字的時候顯示可以清
如上一篇博客《Android動畫之一:Drawable Animation》所說,android動畫主要分為三大部分,上一篇博客已經講解Drawable Animatio
JSON的定義: 一種輕量級的數據交換格式,具有良好的可讀和便於快速編寫的特性。業內主流技術為其提供了完整的解決方案(有點類似於正則表達式 ,獲得了當今大部分語言的支持)