MultiDex与热修复实现原理(二)MultiDex基本原理及解析
来源:互联网 发布:金融数据库软件 编辑:程序博客网 时间:2024/05/26 08:41
当一个app的功能越来越复杂,代码量越来越多,可以遇到下面两种情况:
- 生成的apk在2.3以前的机器无法安装,提示INSTALL_FAILED_DEXOPT
- 方法数量过多,编译时出错,提示:Conversion to Dalvik format failed:Unable to execute dex: method IDnot in [0, 0xffff]: 65536
原因:
- Android2.3及以前版本用来执行dexopt(用于优化dex文件)的内存只分配了5M
- 一个dex文件最多只支持65536个方法。
解决方案:
- 使用Multidex,将编译好的class文件拆分打包成两个dex,绕过dex方法数量的限制以及安装时的检查,在运行时再动态加载第二个dex文件中。
- 使用插件化,将功能模块分离,减少宿主apk的大小和代码。
这里主要来说说Multidex的原理。
基本原理:
除了第一个dex文件(即正常apk包唯一包含的Dex文件),其它dex文件都以资源的方式放在安装包中。所以我们需要将其他dex文件并在Application的onCreate回调中注入到系统的ClassLoader。并且对于那些在注入之前已经引用到的类(以及它们所在的jar),必须放入第一个Dex文件中。
PathClassLoader作为默认的类加载器,在打开应用程序的时候PathClassLoader就去加载指定的apk(解压成dex,然后在优化成odex),也就是第一个dex文件是PathClassLoader自动加载的。所以,我们需要做的就是将其他的dex文件注入到这个PathClassLoader中去。
因为PathClassLoader和DexClassLoader的原理基本一致,从前面的分析来看,我们知道PathClassLoader里面的dex文件是放在一个Element数组里面,可以包含多个dex文件,每个dex文件是一个Element,所以我们只需要将其他的dex文件放到这个数组中去就可以了。
实现:
- 通过反射获取PathClassLoader中的DexPathList中的Element数组(已加载了第一个dex包,由系统加载)
- 通过反射获取DexClassLoader中的DexPathList中的Element数组(将第二个dex包加载进去)
- 将两个Element数组合并之后,再将其赋值给PathClassLoader的Element数组
谷歌提供的MultiDex支持库就是按照这个思路来实现的。
如何使用:
1、修改Gradle的配置,支持multidex:
android { compileSdkVersion 21 buildToolsVersion "21.1.0" defaultConfig { ... minSdkVersion 14 targetSdkVersion 21 ... // Enabling multidex support. multiDexEnabled true } ...}dependencies { compile 'com.android.support:multidex:1.0.0'}
在manifest文件中,在application标签下添加MultidexApplication Class的引用,如下所示:
<!--?xml version="1.0" encoding="utf-8"?--><manifest package="com.example.android.multidex.myapplication" xmlns:android="http://schemas.android.com/apk/res/android"> ... </application></manifest>
了解一下源码:
MultiDexApplication类。
public class MultiDexApplication extends Application { public MultiDexApplication() { } protected void attachBaseContext(Context base) { super.attachBaseContext(base); MultiDex.install(this); }}
原来要求使用MultiDexApplication的原因就是它重写了Application,主要是为了将其他dex文件注入到系统的ClassLoader。
MultiDex.install(this)方法。
//源码public static void install(Context context) { if(IS_VM_MULTIDEX_CAPABLE) { Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled."); // 可以看到,MultiDex不支持SDK版本小于4的系统 } else if(VERSION.SDK_INT < 4) { throw new RuntimeException("Multi dex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + "."); } else { try { // 获取到应用信息 ApplicationInfo e = getApplicationInfo(context); if(e == null) { return; } Set var2 = installedApk; synchronized(installedApk) { // 得到我们这个应用的apk文件路径 // 拿到这个apk文件路径之后,后面就可以从中提取出其他的dex文件 // 并且加载dex放到一个Element数组中 String apkPath = e.sourceDir; if(installedApk.contains(apkPath)) { return; } // 将这个apk文件路径放到一个set中 installedApk.add(apkPath); // 得到classLoader,它就是PathClassLoader // 后面就可以从这个PathClassLoader中拿到DexPathList中的Element数组 // 这个数组里面就包括由系统加载第一个dex包 ClassLoader loader; try { loader = context.getClassLoader(); } catch (RuntimeException var9) { Log.w("MultiDex", "Failure while trying to obtain Context class loader. Must be running in test mode. Skip patching.", var9); return; } // 得到apk解压后得到的dex文件的存放目录,放到应用的data目录下 File dexDir = new File(e.dataDir, SECONDARY_FOLDER_NAME); // 这个方法就是从apk中提取dex文件,放到data目录下 List files = MultiDexExtractor.load(context, e, dexDir, false); if(checkValidZipFiles(files)) { // 这个方法就是将其他的dex文件注入到系统classloader中的具体操作 installSecondaryDexes(loader, dexDir, files); } else { files = MultiDexExtractor.load(context, e, dexDir, true); installSecondaryDexes(loader, dexDir, files); } } } catch (Exception var11) { Log.e("MultiDex", "Multidex installation failure", var11); throw new RuntimeException("Multi dex installation failed (" + var11.getMessage() + ")."); } }}//重构代码public static void install(Context context) { Log.i(TAG, "install"); // 1. 判读是否需要执行MultiDex。 if (IS_VM_MULTIDEX_CAPABLE) { Log.i(TAG, "VM has multidex support, MultiDex support library is disabled."); return; } if (Build.VERSION.SDK_INT < MIN_SDK_VERSION) { throw new RuntimeException("Multi dex installation failed. SDK " + Build.VERSION.SDK_INT + " is unsupported. Min SDK version is " + MIN_SDK_VERSION + "."); } try { ApplicationInfo applicationInfo = getApplicationInfo(context); if (applicationInfo == null) { // Looks like running on a test Context, so just return without patching. return; } // 2. 如果这个方法已经调用过一次,就不能再调用了。 synchronized (installedApk) { String apkPath = applicationInfo.sourceDir; if (installedApk.contains(apkPath)) { return; } installedApk.add(apkPath); // 3. 如果当前Android版本已经自身支持了MultiDex,依然可以执行MultiDex操作, // 但是会有警告。 if (Build.VERSION.SDK_INT > MAX_SUPPORTED_SDK_VERSION) { Log.w(TAG, "MultiDex is not guaranteed to work in SDK version " + Build.VERSION.SDK_INT + ": SDK version higher than " + MAX_SUPPORTED_SDK_VERSION + " should be backed by " + "runtime with built-in multidex capabilty but it's not the " + "case here: java.vm.version=\"" + System.getProperty("java.vm.version") + "\""); } // 4. 获取当前的ClassLoader实例,后面要做的工作,就是把其他dex文件加载后, // 把其DexFile对象添加到这个ClassLoader实例里就完事了。 ClassLoader loader; try { loader = context.getClassLoader(); } catch (RuntimeException e) { Log.w(TAG, "Failure while trying to obtain Context class loader. " + "Must be running in test mode. Skip patching.", e); return; } if (loader == null) { Log.e(TAG, "Context class loader is null. Must be running in test mode. " + "Skip patching."); return; } try { // 5. 清除旧的dex文件,注意这里不是清除上次加载的dex文件缓存。 // 获取dex缓存目录是,会优先获取/data/data/<package>/code-cache作为缓存目录。 // 如果获取失败,则使用/data/data/<package>/files/code-cache目录。 // 这里清除的是第二个目录。 clearOldDexDir(context); } catch (Throwable t) { Log.w(TAG, "Something went wrong when trying to clear old MultiDex extraction, " + "continuing without cleaning.", t); } // 6. 获取缓存目录(/data/data/<package>/code-cache)。 File dexDir = getDexDir(context, applicationInfo); // 7. 加载缓存文件(如果有)。 List<File> files = MultiDexExtractor.load(context, applicationInfo, dexDir, false); // 8. 检查缓存的dex是否安全 if (checkValidZipFiles(files)) { // 9. 安装缓存的dex installSecondaryDexes(loader, dexDir, files); } else { // 9. 从apk压缩包里面提取dex文件 Log.w(TAG, "Files were not valid zip files. Forcing a reload."); files = MultiDexExtractor.load(context, applicationInfo, dexDir, true); if (checkValidZipFiles(files)) { // 10. 安装提取的dex installSecondaryDexes(loader, dexDir, files); } else { throw new RuntimeException("Zip files were not valid."); } } } } catch (Exception e) { Log.e(TAG, "Multidex installation failure", e); throw new RuntimeException("Multi dex installation failed (" + e.getMessage() + ")."); } Log.i(TAG, "install done");}
具体代码的分析已经在上面代码的注释里给出了,从这里我们也可以看出,整个MultiDex.install(Context)的过程中,关键的步骤就是MultiDexExtractor#load方法和MultiDex#installSecondaryDexes方法。
(这部分是题外话)其中有个MultiDex#clearOldDexDir(Context)方法,这个方法的作用是删除/data/data//files/code-cache,一开始我以为这个方法是删除上一次执行MultiDex后的缓存文件,不过这明显不对,不可能每次MultiDex都重新解压dex文件一边,这样每次启动会很耗时,只有第一次冷启动的时候才需要解压dex文件。后来我又想是不是以前旧版的MultiDex曾经把缓存文件放在这个目录里,现在新版本只是清除以前旧版的遗留文件?但是我找遍了整个MultiDex Repo的提交也没有见过类似的旧版本代码。后面我仔细看MultiDex#getDexDir这个方法才发现,原来MultiDex在获取dex缓存目录是,会优先获取/data/data//code-cache作为缓存目录,如果获取失败,则使用/data/data//files/code-cache目录,而后者的缓存文件会在每次App重新启动的时候被清除。感觉MultiDex获取缓存目录的逻辑不是很严谨,而获取缓存目录失败也是MultiDex工作工程中少数有重试机制的地方,看来MultiDex真的是一个临时的兼容方案,Google也许并不打算认真处理这些历史的黑锅。
MultiDexExtractor.load方法
static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir, boolean forceReload) throws IOException { Log.i(TAG, "MultiDexExtractor.load(" + applicationInfo.sourceDir + ", " + forceReload + ")"); final File sourceApk = new File(applicationInfo.sourceDir); // 1. 获取当前Apk文件的crc值。 long currentCrc = getZipCrc(sourceApk); // Validity check and extraction must be done only while the lock file has been taken. File lockFile = new File(dexDir, LOCK_FILENAME); RandomAccessFile lockRaf = new RandomAccessFile(lockFile, "rw"); FileChannel lockChannel = null; FileLock cacheLock = null; List<File> files; IOException releaseLockException = null; try { lockChannel = lockRaf.getChannel(); Log.i(TAG, "Blocking on lock " + lockFile.getPath()); // 2. 加上文件锁,防止多进程冲突。 cacheLock = lockChannel.lock(); Log.i(TAG, lockFile.getPath() + " locked"); // 3. 先判断是否强制重新解压,这里第一次会优先使用已解压过的dex文件,如果加载失败就强制重新解压。 // 此外,通过crc和文件修改时间,判断如果Apk文件已经被修改(覆盖安装),就会跳过缓存重新解压dex文件。 if (!forceReload && !isModified(context, sourceApk, currentCrc)) { try { // 4. 加载缓存的dex文件 files = loadExistingExtractions(context, sourceApk, dexDir); } catch (IOException ioe) { Log.w(TAG, "Failed to reload existing extracted secondary dex files," + " falling back to fresh extraction", ioe); // 5. 加载失败的话重新解压,并保存解压出来的dex文件的信息。 files = performExtractions(sourceApk, dexDir); putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1); } } else { // 4. 重新解压,并保存解压出来的dex文件的信息。 Log.i(TAG, "Detected that extraction must be performed."); files = performExtractions(sourceApk, dexDir); putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1); } } finally { if (cacheLock != null) { try { cacheLock.release(); } catch (IOException e) { Log.e(TAG, "Failed to release lock on " + lockFile.getPath()); // Exception while releasing the lock is bad, we want to report it, but not at // the price of overriding any already pending exception. releaseLockException = e; } } if (lockChannel != null) { closeQuietly(lockChannel); } closeQuietly(lockRaf); } if (releaseLockException != null) { throw releaseLockException; } Log.i(TAG, "load found " + files.size() + " secondary dex files"); return files; }
这个过程主要是获取可以安装的dex文件列表,可以是上次解压出来的缓存文件,也可以是重新从Apk包里面提取出来的。需要注意的时,如果是重新解压,这里会有明显的耗时,而且解压出来的dex文件,会被压缩成.zip压缩包,压缩的过程也会有明显的耗时(这里压缩dex文件可能是问了节省空间)。
如果dex文件是重新解压出来的,则会保存dex文件的信息,包括解压的apk文件的crc值、修改时间以及dex文件的数目,以便下一次启动直接使用已经解压过的dex缓存文件,而不是每一次都重新解压。
需要特别提到的是,里面的FileLock是最新的master分支里面新加进去的功能,现在最新的1.0.1版本里面是没有的。
无论是通过使用缓存的dex文件,还是重新从apk中解压dex文件,获取dex文件列表后,下一步就是安装(或者说加载)这些dex文件了。最后的工作在MultiDex#installSecondaryDexes这个方法里面。
MultiDex#installSecondaryDexes方法。
// loader对应的就是PathClassLoader// dexDir是dex的存放目录// files对应的就是dex文件private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException { if (!files.isEmpty()) { if (Build.VERSION.SDK_INT >= 19) { V19.install(loader, files, dexDir); } else if (Build.VERSION.SDK_INT >= 14) { V14.install(loader, files, dexDir); } else { V4.install(loader, files); } }}
因为在不同的SDK版本上,ClassLoader(更准确来说是DexClassLoader)加载dex文件的方式有所不同,所以这里做了V4/V14/V19的兼容(Magic Code)
Build.VERSION.SDK_INT < 14
/** * Installer for platform versions 4 to 13. */private static final class V4 { private static void install(ClassLoader loader, List<File> additionalClassPathEntries) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, IOException { int extraSize = additionalClassPathEntries.size(); Field pathField = findField(loader, "path"); StringBuilder path = new StringBuilder((String) pathField.get(loader)); String[] extraPaths = new String[extraSize]; File[] extraFiles = new File[extraSize]; ZipFile[] extraZips = new ZipFile[extraSize]; DexFile[] extraDexs = new DexFile[extraSize]; for (ListIterator<File> iterator = additionalClassPathEntries.listIterator(); iterator.hasNext();) { File additionalEntry = iterator.next(); String entryPath = additionalEntry.getAbsolutePath(); path.append(':').append(entryPath); int index = iterator.previousIndex(); extraPaths[index] = entryPath; extraFiles[index] = additionalEntry; extraZips[index] = new ZipFile(additionalEntry); extraDexs[index] = DexFile.loadDex(entryPath, entryPath + ".dex", 0); } // 这个版本是最简单的。 // 只需要创建DexFile对象后,使用反射的方法分别扩展ClassLoader实例的以下字段即可。 pathField.set(loader, path.toString()); expandFieldArray(loader, "mPaths", extraPaths); expandFieldArray(loader, "mFiles", extraFiles); expandFieldArray(loader, "mZips", extraZips); expandFieldArray(loader, "mDexs", extraDexs); }}
MultiDex.V14.install方法。
/** * Installer for platform versions 14, 15, 16, 17 and 18. */private static final class V14 { private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException { // 这个方法就是使用反射来得到loader的pathList字段 Field pathListField = findField(loader, "pathList"); // 得到loader的pathList字段后,我们就可以得到这个字段的值,也就是DexPathList对象 Object dexPathList = pathListField.get(loader); // 这个方法就是将其他的dex文件Element数组和第一个dex的Element数组合并 //makeDexElements方法就是用来得到其他dex的Elements数组 expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList<File>(additionalClassPathEntries), optimizedDirectory)); } private static Object[] makeDexElements( Object dexPathList, ArrayList<File> files, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { Method makeDexElements = findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class); return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory); }}
合并的过程expandFieldArray方法。
// instance对应的就是pathList对象// fieldName 对应的就是字段名,我们要得到的就是pathList对象里面的dexElements数组// extraElements对应的就是其他dex对应的Element数组private static void expandFieldArray(Object instance, String fieldName, Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException { // 得到Element数组字段 Field jlrField = findField(instance, fieldName); // 得到pathList对象里面的dexElements数组 Object[] original = (Object[])((Object[])jlrField.get(instance)); // 创建一个新的数组用来存放合并之后的结果 Object[] combined = (Object[])((Object[])Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length)); // 将第一个dex的Elements数组复制到创建的数组中去 System.arraycopy(original, 0, combined, 0, original.length); // 将其他dex的Elements数组复制到创建的数组中去 System.arraycopy(extraElements, 0, combined, original.length, extraElements.length); // 将得到的这个合并的新数组的值设置到pathList对象的Element数组字段上 jlrField.set(instance, combined);}
从API14开始,DexClassLoader会使用一个DexpDexPathList类来封装DexFile数组。
final class DexPathList { private static final String DEX_SUFFIX = ".dex"; private static final String JAR_SUFFIX = ".jar"; private static final String ZIP_SUFFIX = ".zip"; private static final String APK_SUFFIX = ".apk"; private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory) { ArrayList<Element> elements = new ArrayList<Element>(); for (File file : files) { ZipFile zip = null; DexFile dex = null; String name = file.getName(); if (name.endsWith(DEX_SUFFIX)) { // Raw dex file (not inside a zip/jar). try { dex = loadDexFile(file, optimizedDirectory); } catch (IOException ex) { System.logE("Unable to load dex file: " + file, ex); } } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX) || name.endsWith(ZIP_SUFFIX)) { try { zip = new ZipFile(file); } catch (IOException ex) { System.logE("Unable to open zip file: " + file, ex); } try { dex = loadDexFile(file, optimizedDirectory); } catch (IOException ignored) { } } else { System.logW("Unknown file type for: " + file); } if ((zip != null) || (dex != null)) { elements.add(new Element(file, zip, dex)); } } return elements.toArray(new Element[elements.size()]); } private static DexFile loadDexFile(File file, File optimizedDirectory) throws IOException { if (optimizedDirectory == null) { return new DexFile(file); } else { String optimizedPath = optimizedPathFor(file, optimizedDirectory); return DexFile.loadDex(file.getPath(), optimizedPath, 0); } }}
通过调用DexPathList#makeDexElements方法,可以加载我们上面解压得到的dex文件,从代码也可以看出,DexPathList#makeDexElements其实也是通过调用DexFile#loadDex来加载dex文件并创建DexFile对象的。V14中,通过反射调用DexPathList#makeDexElements方法加载我们需要的dex文件,在把加载得到的数组扩展到ClassLoader实例的”pathList”字段,从而完成dex文件的安装。
从DexPathList的代码中我们也可以看出,ClassLoader是支持直接加载.dex/.zip/.jar/.apk的dex文件包的(我记得以前在哪篇日志中好像提到过类似的问题…)。
19 <= Build.VERSION.SDK_INT
/** * Installer for platform versions 19. */private static final class V19 { private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException { Field pathListField = findField(loader, "pathList"); Object dexPathList = pathListField.get(loader); ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>(); expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList<File>(additionalClassPathEntries), optimizedDirectory, suppressedExceptions)); if (suppressedExceptions.size() > 0) { for (IOException e : suppressedExceptions) { Log.w(TAG, "Exception in makeDexElement", e); } Field suppressedExceptionsField = findField(dexPathList, "dexElementsSuppressedExceptions"); IOException[] dexElementsSuppressedExceptions = (IOException[]) suppressedExceptionsField.get(dexPathList); if (dexElementsSuppressedExceptions == null) { dexElementsSuppressedExceptions = suppressedExceptions.toArray( new IOException[suppressedExceptions.size()]); } else { IOException[] combined = new IOException[suppressedExceptions.size() + dexElementsSuppressedExceptions.length]; suppressedExceptions.toArray(combined); System.arraycopy(dexElementsSuppressedExceptions, 0, combined, suppressedExceptions.size(), dexElementsSuppressedExceptions.length); dexElementsSuppressedExceptions = combined; } suppressedExceptionsField.set(dexPathList, dexElementsSuppressedExceptions); } } private static Object[] makeDexElements( Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { Method makeDexElements = findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class, ArrayList.class); return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions); }}
V19与V14差别不大,只不过DexPathList#makeDexElements方法多了一个ArrayList参数,如果在执行DexPathList#makeDexElements方法的过程中出现异常,后面使用反射的方式把这些异常记录进DexPathList的dexElementsSuppressedExceptions字段里面。
无论是V4/V14还是V19,在创建DexFile对象的时候,都需要通过DexFile的Native方法openDexFile来打开dex文件,其具体细节暂不讨论(涉及到dex的文件结构,很烦,有兴趣请阅读dalvik_system_DexFile.cpp),这个过程的主要目的是给当前的dex文件做Optimize优化处理并生成相同文件名的odex文件,App实际加载类的时候,都是通过odex文件进行的。因为每个设备对odex格式的要求都不一样,所以这个优化的操作只能放在安装Apk的时候处理,主dex的优化我们已经在安装apk的时候搞定了,其余的dex就是在MultiDex#installSecondaryDexes里面优化的,而后者也是MultiDex过程中,另外一个耗时比较多的操作。(在MultiDex中,提取出来的dex文件被压缩成.zip文件,又优化后的odex文件则被保存为.dex文件。)
- MultiDex与热修复实现原理(二)MultiDex基本原理及解析
- MultiDex与热修复实现原理
- MultiDex与热修复实现原理(一)ClassLoader原理
- Android MultiDex实现原理解析
- Android MultiDex实现原理解析
- Android Multidex原理及实现
- 关于Android APP在线热修复bug方案的调研(二)(MultiDex的原理分析---Nuwa)
- Android 利用MultiDex方案实现热补丁修复
- android MultiDex multiDex原理(一)
- android MultiDex multidex原理原理下遇见的N个深坑(二)
- Android MultiDex 解析与使用
- MultiDex
- MultiDex
- MultiDex
- multidex
- multidex
- Android热修复三部曲之MultiDex 分包架构
- Android热修复三部曲之MultiDex 分包架构
- ubuntu gcc编译时对’xxxx’未定义的引用问题
- 洛谷1781宇宙总统
- Hololens官方教程精简版 - 07. Spatial mapping(空间映射)
- Tesseract-OCR引擎安装
- Linux 中文乱码问题
- MultiDex与热修复实现原理(二)MultiDex基本原理及解析
- Qt实现推拉效果
- Codeforces 749D Leaving Auction binary search+data structures
- XX-Net使用:使用Chrome浏览器(二)
- LeetCode 320. Generalized Abbreviation
- 使用Policy文件来设置Java的安全策略
- 图解Sysprep封装系统
- matlab实现DPCM编码和游长编码
- photoshop-反相&调整颜色