App极限瘦身 | 动态下发so(1)

简介: App极限瘦身 | 动态下发so

前言

一般来说,作为一个成熟的应用,native 文件会越来越多,资源文件过大安卓的包体越来越大,包体积的增长,逐渐带来了一些不利影响,比如用户安装成功率降低,CDN 流量费用增加,流失部分付费渠道方,新拓展的渠道方抱怨包体过大,限制了渠道拓展,那么如何解决这种业务痛点呢? 今天就详细给大家介绍一下 Android 动态化管理 so

比如 某游戏 项目就同时支持 arm32/arm64/x86/x86-V7a 四种 ,so 体积成倍地上涨。因此,能不能将非主要的 abi 相关的 so 文件动态化,也成为了国际化出海项目瘦身优化不得不优先考虑的问题。希望能通过包体优化,降低流量成本,避免由于包体过大导致的用户流失。

系统加载 so 库的工作流程

当我们调用当调用 System#loadLibrary("xxx" ) 后,Android Framework 都干了些了啥?

1681598955464.png

大致流程示意图如下:

1681599078409.png

市场调研

方案分析:

1. JNI 代码内置方案

1681599144327.png

代码隔离方案比较适合新增的 Native 模块,一开始就奔着动态化、延迟加载的方向去。

2. 插件化方案

1681599200984.png

单独把 so 文件单独打包进插件包,JNI 代码保留在宿主代码内部,插件化方案虽然比较不错,但是向 nativeLibraryDirectories 注入 so 插件路径带来的 集合并发修改 问题。由于 nativeLibraryDirectories 的具体实现是一个 ArrayList 实例,其元素读写操作自身是不保证线程安全的,而我们在 Worker 线程加载 so 插件的环节最后需要将新的 so 文件路径注入到 ArrayList 集合里,如果这时候刚好有另一个线程因为执行“so loading”操作而正在遍历集合元素,则会抛出 ConcurrentModificationException(ArrayList 内部实现)

方案落地

经过一轮调查发现 杜小菜 so 动态加载方案 还是值得推荐的,他有如下优势:

  1. 注入路径后,加载 so 的姿势不变
  2. 支持各种 CPU 架构平台
  3. 按需加载 so

急需解决的问题

1. 安全性问题

所有可执行代码在拷贝安装到安全路径(比如 Android 的 data/data 内部路径)之前,都有被劫持或者破坏的风险。so 动态化也不得不考虑这个安全性问题,最好的做法是每次加载 so 库之前都对其做一次安全性校验。那么怎么做安全性检查呢?

最简单的方式是记录 so 文件的 MD5 或者 CRC 等 Hash 信息(粒度可以是每个单独的 so 文件,或者一批 so 文件的压缩包),将信息内置到 APK 内部或者服务器(如果保存在服务器,客户端需要通过类似 HTTPS 之类的可信通道获取这些数据),通过校验 so 文件 Hash 信息是否一致来确保安全性。

我们具体看一下代码实现吧~

// ARM手机主动检测so,进入核心的activity时,开启延时检测,先停掉下载,减少对页面加载的影响,x秒后重新下载
    public void checkArmSoDelayWhenCreate(int delayMillis) {
        //test();//  todo 测试时开启,并注释以下代码,方便测试下载和加载so的交互
        if (localSoStatus.isDownloading) {
            ThreadManager.getBackgroundPool().execute(this::pauseDownloadTask);
        }
        weakHandler.removeCallbacks(startCheckRunnable);
        weakHandler.postDelayed(startCheckRunnable, delayMillis);
    }

放在单独的线程中检测 so 是否完整

    private void checkSoLibsInBackThread() {
        weakHandler.removeCallbacks(startCheckRunnable);
        final ThreadManager.ThreadPoolProxy singlePool = ThreadManager.getSinglePool("so-download");
        //避免产生重复的检测任务。
        singlePool.remove(checkSoRunnable);
        singlePool.execute(checkSoRunnable);
    }

接下来我们详细了解一下具体的检测逻辑吧

  • 3.1 zip 文件存在,则校验是否合法,md5 校验
  String soZipPath = soFileDownloader.getSoZipFilePath(SOURCE_MD5);
        final boolean allSoFilesExist = isAllSoFilesExist(soZipPath);
        //统计触发检测时,不存在so的情况
        StatisticsForSoLoader.sendSoFilesNotExist(allSoFilesExist);
        boolean hasInstalledSoPath = soFileDownloader.hasInstalledSoPath();
        localSoStatus.hasInstalledSoPath = hasInstalledSoPath;
        final boolean isPrepared = allSoFilesExist && hasInstalledSoPath;
  • 3.2 完整解压,不完整删除缓存,重新下载
      localSoStatus.isPrepared = isPrepared;
       Log.d(TAG, "handleSoBackground isPrepared=" + isPrepared);
       if (isPrepared) {//一切就绪,回调出去,ok
           if (soLoaderListener != null) {
               soLoaderListener.prepared(true);
           } else {//回调出去继续执行
               MkWeexSoLoader.reloadWeexSoLib(this::notifyCallback);
           }
           return;
       }
           private void startDownload(SoLoaderListener soLoaderListener, String soZipPath) {
               //pauseDownloadTask();//每次下载前暂停上次任务,防止so读写出现问题
               String soUrl = getServerUrl();
               soFileDownloader.downloadSoFile(soUrl, soZipPath, soLoaderListener);
           }
  • 3.3 是否存在 soNameList 里面指定的 so 文件
       for (File currentFile : currentFiles) {
            final String currentFileName = currentFile.getName();
            //so库,size>0,且是预先定义的合法so,统计so个数
            final boolean contains = allSoNameList.contains(currentFileName);
            if (currentFileName.endsWith(".so") && currentFile.length() > 0 && contains) {
                localSoFileCount++;
            }
        }
        //如果本地下载目录中的so文件总数目,少于应该有的so文件数目,说明不完整
        localSoStatus.isAllSoFilesExist = localSoFileCount >= allSoNameList.size();
        return localSoStatus.isAllSoFilesExist;
  • 然后下载 so 库 zip 包,比对服务端的 MD5 值和客户端的 MD5 值是否一致
          localSoStatus.hasInstalledSoPath = hasInstalledSoPath;
                localSoStatus.hasDownloadSoZip = true;//标记是否下载成功
                localSoStatus.isZipLegal = checkSoZipMd5;//标记zip是否合法,md5校验ok则认为合法
                localSoStatus.isDownloading = false;
                boolean isPrepared = false;
                if (!checkSoZipMd5) {
                    StatisticsForSoLoader.sendZipFileStatus(false);//统计不合法
                    deleteOldCache();//不合法删除
                }

判断 zip 是否有更新,如果有更新,则需要重新下载

  public boolean isZipNeedUpdate(String md5) {
        String zipDirPath = getDownloadZipTempDir() + File.separator + md5;
        File zipRootFile = new File(zipDirPath);
        if (!zipRootFile.exists()) {//如果带md5的zip缓存路径不存在,说明需要重新下载,so更新了。
            Log.d(TAG, "app upgrade...");
            StatisticsForSoLoader.sendOverwriteInstallApp();
            deleteOldCache();//so更新,删除旧的zip缓存和so文件
            return true;
        }
        return false;
    }
  • 下载完成后,解压,解压经常失败,所以要进行两次解压处理
       //下载成功
                    handler.post(() -> {
                        if (listener != null) {
                            listener.download(true, soUrl);
                        }
                    });
                    //解压
                    final boolean unZip = doUnZip(soZipPath);
                    //重新校验文件完整性
                    isPrepared = notifyPrepared(hasInstalledSoPath);
                    localSoStatus.isPrepared = isPrepared;
                    localSoStatus.isUnZip = unZip;//标记是否下载成功
                    if (isPrepared) {//加载成功后重新加载一次weex的库
                        MkWeexSoLoader.reloadWeexSoLib(() -> notifyPreparedCallback(true));
                    } else {
                        notifyPreparedCallback(false);
                    }
  • 里面核心还是通过 ZipInputStream 实现的
            is = new ZipInputStream(new FileInputStream(zipFilePath));
            ZipEntry zipEntry;
            while ((zipEntry = is.getNextEntry()) != null) {
                String subfilename = zipEntry.getName();
                if (zipEntry.isDirectory()) {
                    File subDire = new File(folderPath + subfilename);
                    if (subDire.exists() && subDire.isDirectory()) {
                        continue;
                    } else if (subDire.exists() && subDire.isFile()) {
                        subDire.delete();
                    }
                    subDire.mkdirs();
                } else {
                    File subFile = new File(folderPath + subfilename);
                    if (subFile.exists()) {
                        continue;
                    }
                    final File parentFile = subFile.getParentFile();
                    if (parentFile != null && !parentFile.exists()) {
                        parentFile.mkdirs();
                    }
                    subFile.createNewFile();
                    os = new FileOutputStream(subFile);
                    int len;
                    byte[] buffer = new byte[5120];
                    while ((len = is.read(buffer)) != -1) {
                        os.write(buffer, 0, len);
                        os.flush();
                    }
                }
            }
  • 解压完毕判断 so 文件是否解压并且完整存在,不完整,需要下载,下载前需要暂停上次任务,防止 so 读写出现问题
    private void startDownload(SoLoaderListener soLoaderListener, String soZipPath) {
        pauseDownloadTask();//每次下载前暂停上次任务,防止so读写出现问题
        String soUrl = getServerUrl();
        soFileDownloader.downloadSoFile(soUrl, soZipPath, soLoaderListener);
    }
  • 如果本地下载目录中的 so 文件总数目,少于预定义在集合里 so 文件数目,说明不完整
  public boolean isSoUnzipAndExist() {
        String targetSoDir = SoFileDownloader.getLocalSoInstallDir();
        File dir = new File(targetSoDir);
        File[] currentFiles = dir.listFiles();
        if (currentFiles == null || currentFiles.length == 0) {
            return false;
        }
        int localSoFileCount = 0;
        for (File currentFile : currentFiles) {
            final String currentFileName = currentFile.getName();
            //so库,size>0,且是预先定义的合法so,统计so个数
            final boolean contains = allSoNameList.contains(currentFileName);
            if (currentFileName.endsWith(".so") && currentFile.length() > 0 && contains) {
                localSoFileCount++;
            }
        }
        //如果本地下载目录中的so文件总数目,少于应该有的so文件数目,说明不完整
        localSoStatus.isAllSoFilesExist = localSoFileCount >= allSoNameList.size();
        return localSoStatus.isAllSoFilesExist;
    }
  • 再看 zip 包是否存在,如果有的话要再次解压
    public synchronized boolean checkSoZipMd5(String soZipPath) {
        if (TextUtils.isEmpty(soZipPath)) {
            return false;
        }
        final File localSoZipFile = new File(soZipPath);
        if (!localSoZipFile.exists() || localSoZipFile.length() == 0) {
            return false;
        }
        final String localSoMd5 = MD5Utils.getMd5(localSoZipFile);
        //Logger.d(TAG, "localSoMd5=" + localSoMd5);
        final boolean md5Equals = MkSoManager.SOURCE_MD5.equals(localSoMd5);
        if (!md5Equals) {//非法zip包直接删除,未下载完成的包不是这个路径,放心!
            FileUtils.deleteFile(soZipPath);
        }
        return md5Equals;
    }
  • 解压完毕后就直接通过 injectLocalSoLibraryPath 注入驱动
    /**
     * 直接指定你so下载的路径
     */
    public static boolean installLocalSoPath(Context context) {
        try {
            String targetSoDir = SoFileDownloader.getLocalSoInstallDir();
            File soDir = new File(targetSoDir);
            if (!soDir.exists()) {
                soDir.mkdirs();
            }
            final ClassLoader classLoader = context.getApplicationContext().getClassLoader();
            boolean hasInstalledSoPath = LoadLibraryUtil.injectLocalSoLibraryPath(classLoader, soDir);
            if (!hasInstalledSoPath) {//只统计注入失败的情况,几乎不存在失败
                StatisticsForSoLoader.sendInstallPathStatus(false);
                Log.d(TAG, "installLocalSoPath=" + false + ",targetDir=" + targetSoDir);
            }
            MkSoManager.get().getLocalSoStatus().hasInstalledSoPath = hasInstalledSoPath;
            return hasInstalledSoPath;
        } catch (Throwable e) {
            Log.e(TAG, "installLocalSoPath error " + e);
        }
        return false;
    }

不过 Hash 信息一般都会随之 so 文件的变动而改变,每次都需要调整这些数据比较麻烦,优化方案是“通过类似 APK 安装包签名校验的方式来确保安全性”:将 so 文件打包成 APK 格式的插件包并使用 Android Keystore 进行签名,将 Keystore 的指纹信息保存在宿主包内部,安全检验环节只需要校验插件包的签名信息是否和内置的指纹信息一致即可,具体可以参考文章链接

2. 版本控制问题

我们发布了某一个版本宿主 APK 和与之对应的 so 插件包,而这个版本的 so 是有 Bug 的可能导致 APP 崩溃。通过版本控制流程,我们可以在服务端禁用这个版本的 so 插件,从而使客户端进入“so 插件不可用”的逻辑,而不至于执行有问题的代码。那么代码该如何实现呢?这边提供了一下伪代码:

    public static void checkX86AndDownload(String baseDownloadUrl) {
        //final boolean isX86Phone = isX86Phone();
        // TODO: 2017/8/3 需要重新构建下载信息,sdk信息+线上的地址和版本号
        if (!checkSoVersion()) {//  || !isX86Phone 无需下载
            return;
        }
        //todo 接口获取下载路径和版本信息
        String cpuABI = MkSoManager.getMkSupportABI();
        String soUrl = baseDownloadUrl + cpuABI + ".zip";
        SoFileDownloader.init().downloadSoFile(cpuABI, soUrl);
    }
    /**
     * 根据服务器版本配置,确定是否下载
     */
    private static boolean checkSoVersion() {
        // TODO: 2017/8/3 与服务端校验,符合当前sdk对应的版本,检测本地so文件完整性
        return true;
    }

3. abi 兼容性判断

abi 兼容性是 so 插件特有的动态化问题,除了考虑 so 插件是否安全之外,我们还需要检查 so 插件包里的 so 库 abi 信息是否与宿主目前运行时的 abi 一致。考虑这么一种情况:宿主 APK 里面内置了 ARM32 和 AMR64 两种 so 文件,同样插件包里也内置这两种 so 文件,当宿主 APK 安装在 ARM32 的设备上,动态加载 so 插件的时候,我们必须只解压并加载相应 AMR32 的 so 插件,对于 ARM64 的设备也是同样的道理。也就是说:同样的 APK 宿主,同样的 so 插件,安装在不同 abi 设备上时,动态化框架的插件处理行为是不一样的,那么具体实现逻辑是怎样的呢?

首先定义一个 LocalSoStatus 类,方便业务对下载逻辑进行自定义扩展

public class LocalSoStatus {
    public boolean hasInstalledSoPath = false;// 是否注入so路径
    public boolean isDownloading = false;// 是否正在下载
    public int progress = 0;// 下载进度
    public boolean hasStartDownload = false;// 是否启动过下载
    public boolean hasDownloadSoZip = false;// 是否成功下载zip文件
    public boolean isZipLegal = false;// zip文件是否合法
    public boolean isUnZip = false;// zip文件是否解压成功
    public boolean isAllSoFilesExist = false;// so文件是否完整存在
    public boolean isPrepared = false;// so注入成功并且本地文件完整
    public boolean hasLoadSoSuccess = false;// 测试是否成功加载过so库
  • 直接指定你 so 下载的路径,通过反射获取 android.os.SystemProperties 私有方法 get ro.product.cpu.abi 可以动态获取 CPU 架构
    /**
     * 获取设备的cpu架构类型
     */
    public static String getCpuArchType() {
        if (!TextUtils.isEmpty(cpuArchType)) {
            return cpuArchType;
        }
        try {
            Class<?> clazz = Class.forName("android.os.SystemProperties");
            Method get = clazz.getDeclaredMethod("get", new Class[]{String.class});
            cpuArchType = (String) get.invoke(clazz, new Object[]{"ro.product.cpu.abi"});
        } catch (Exception e) {
        }
        try {
            if (TextUtils.isEmpty(cpuArchType)) {
                cpuArchType = Build.CPU_ABI;//获取不到,重新获取,可能不准确?
            }
        } catch (Exception e) {
        }
        if (TextUtils.isEmpty(cpuArchType)) {
            cpuArchType = "armeabi-v7a";
        }
        cpuArchType = cpuArchType.toLowerCase();
        return cpuArchType;
    }

4. System#load 加载代码侵入问题

使用 System.load("{安全路径}/libxxx.so") 。Native 代码在开发阶段完全可以用传统的内置方案进行调试,在集成阶段再按动态化的方案打包,这也就意味着我们必须频繁地在 System#load 和 System#loadLibrary("xxx" ) 直接来回修改,代码侵入性问题非常严重

通过 System#loadLibrary("xxx" ) 加载 so 库, Android Framework 会遍历当前上下文的 ClassLoader 实例里的 nativeLibraryDirectories 数组,在数组里所有的文件路径下查找文件名为 libxxx.so 的文件,所以我们的解决思路就是在安装好 so 插件之后,将其所在的内部安全路径注入到这个 nativeLibraryDirectories 数组里,即可实现通过 System#loadLibrary 加载,代码如下:

第一步: 通过反射,注入 so 文件注入到 nativeLibraryDirectories 路径
    private static final class V14 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
           // 反射宿主 APK 的 ClassLoader 的 pathList成员变量
            Field pathListField = MkReflectUtil.findField(classLoader, "pathList");
            // 获取这个成员变量 在 宿主 APK 的 ClassLoader 对象的取值
            Object dexPathList = pathListField.get(classLoader);
            // 将被加载的 被加载的 so 实例存储到 dexPathList
            MkReflectUtil.expandArray(dexPathList, "nativeLibraryDirectories", new File[]{folder});
        }
    }

需要注意的事项是: 不同的系统 SDK 版本因为其版本差异性,需要执行不同反射逻辑

SDK 版本: 14
    private static final class V14 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
           // 反射宿主 APK 的 ClassLoader 的 pathList成员变量
            Field pathListField = MkReflectUtil.findField(classLoader, "pathList");
            // 获取这个成员变量 在 宿主 APK 的 ClassLoader 对象的取值
            Object dexPathList = pathListField.get(classLoader);
            // 将被加载的 被加载的 so 实例存储到 dexPathList
            MkReflectUtil.expandArray(dexPathList, "nativeLibraryDirectories", new File[]{folder});
        }
    }

SDK 版本: 23

 private static final class V23 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
            Field pathListField = MkReflectUtil.findField(classLoader, "pathList");
            Object dexPathList = pathListField.get(classLoader);
            Field nativeLibraryDirectories = MkReflectUtil.findField(dexPathList, "nativeLibraryDirectories");
            List<File> libDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
            //去重
            if (libDirs == null) {
                libDirs = new ArrayList<>(2);
            }
            final Iterator<File> libDirIt = libDirs.iterator();
            while (libDirIt.hasNext()) {
                final File libDir = libDirIt.next();
                if (folder.equals(libDir)) {
                    libDirIt.remove();
                    Log.d(TAG, "dq libDirIt.remove() " + folder.getAbsolutePath());
                    break;
                }
            }
            libDirs.add(0, folder);
            Field systemNativeLibraryDirectories =
                    MkReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
            List<File> systemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
            //判空
            if (systemLibDirs == null) {
                systemLibDirs = new ArrayList<>(2);
            }
            //Log.d(TAG, "dq systemLibDirs,size=" + systemLibDirs.size());
            // 获得Element[] 数组
            Method makePathElements = MkReflectUtil.findMethod(dexPathList, "makePathElements", List.class, File.class, List.class);
            ArrayList<IOException> suppressedExceptions = new ArrayList<>();
            libDirs.addAll(systemLibDirs);
           // 输出调用对象,插件APK所在目录,插件APK的全路径,和用于存储IO异常的List,获得Element[] 返回
            Object[] elements = (Object[]) makePathElements.invoke(dexPathList, libDirs, null, suppressedExceptions);
            Field nativeLibraryPathElements = MkReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
            nativeLibraryPathElements.setAccessible(true);
            nativeLibraryPathElements.set(dexPathList, elements);
        }
    }

SDK 版本: 25

 private static final class V25 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
            Field pathListField = MkReflectUtil.findField(classLoader, "pathList");
            Object dexPathList = pathListField.get(classLoader);
            Field nativeLibraryDirectories = MkReflectUtil.findField(dexPathList, "nativeLibraryDirectories");
            List<File> libDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
            //去重
            if (libDirs == null) {
                libDirs = new ArrayList<>(2);
            }
            final Iterator<File> libDirIt = libDirs.iterator();
            while (libDirIt.hasNext()) {
                final File libDir = libDirIt.next();
                if (folder.equals(libDir)) {
                    libDirIt.remove();
                    Log.d(TAG, "dq libDirIt.remove()" + folder.getAbsolutePath());
                    break;
                }
            }
            libDirs.add(0, folder);
            //system/lib
            Field systemNativeLibraryDirectories = MkReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
            List<File> systemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
            //判空
            if (systemLibDirs == null) {
                systemLibDirs = new ArrayList<>(2);
            }
            //Log.d(TAG, "dq systemLibDirs,size=" + systemLibDirs.size());
            Method makePathElements = MkReflectUtil.findMethod(dexPathList, "makePathElements", List.class);
            libDirs.addAll(systemLibDirs);
            Object[] elements = (Object[]) makePathElements.invoke(dexPathList, libDirs);
            Field nativeLibraryPathElements = MkReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
            nativeLibraryPathElements.setAccessible(true);
            nativeLibraryPathElements.set(dexPathList, elements);
        }
    }

注入 so 路径的逻辑如下:

  1. APK 的 ClassLoader 的 pathList 的成员变量,
  2. pathList 实际上是 SoPathList, 类的实例 的内部 成员变量 List 实例
  3. 这个 List 存储的是 被加载的 so 文件实例

我们看一下代码实现吧~

    /**
     * 1. 通过反射拿到dexElements的取值
     * 2. 将 findField 方法获取到的 object[] 插入到数组的最前面。
     * 3. 被插入的 object[] 数组就是外部修复包存储路径集合编译后形成的队列
     *    即外部修复包的资源和 .class 队列
     * @param instance 宿主 APK 的 ClassLoader实例的成员变量 pathList(DexPathList类似)
     * @param fieldName 需要被反射和替换的 DexPathList 类对象的成员变量 "dexElements", 用于存储 .dex 加载对象dex
     * @param extraElements 被加载的插件 apk 的 .dex实例列表
     */
    public static void expandFieldArray(Object instance, String fieldName, Object[] extraElements)
            throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
        // 1 通过反射获取 classLoader 实例的成员变量 pathList(DexPathList类的实例)的成员变量dexElements
        Field jlrField = findField(instance, fieldName);
        // 2 获取当前dexElements 这个成员变量在classLoader 实例的成员变量 pathList(DexPathList类的实例)中的取值
        Object[] original = (Object[]) jlrField.get(instance);
        // 3 新建一个数组,这个数组用来容纳 宿主 apk .dex 文件加载出来的elements[] 和 插件apk .dex 文件加载出来的 elements[]
        Object[] combined = (Object[]) Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length);
      // 4 先把插件 apk 中获取的elements[] 以及 dexFileArr复制到数组里面,方便我们动态加载
        System.arraycopy(extraElements, 0, combined, 0, extraElements.length);
       // 5 再把apk所有的 dexElements 成员变量取值复制到数组里面
        System.arraycopy(original, 0, combined, extraElements.length, original.length);
         // 6 覆盖 dexElements 成员变量取值
        jlrField.set(instance, combined);
    }


相关文章
|
6月前
|
存储 Java API
Android 浅度解析:mk预置AAR、SO文件、APP包和签名
Android 浅度解析:mk预置AAR、SO文件、APP包和签名
781 0
|
6月前
uniapp App端 解决input@input事件动态修改值不生效的问题
uniapp App端 解决input@input事件动态修改值不生效的问题
385 1
|
6月前
|
XML Java Android开发
Android App开发之图像加工中给图像添加水波动态特效(附源码和演示视频 简单易懂)
Android App开发之图像加工中给图像添加水波动态特效(附源码和演示视频 简单易懂)
81 0
|
12月前
uniapp App端 解决input@input事件动态修改值不生效的问题
uniapp App端 解决input@input事件动态修改值不生效的问题
280 0
App逆向百例|18|某A系防护SO跳转修复
App逆向百例|18|某A系防护SO跳转修复
519 0
|
存储 缓存 监控
毕业设计So Easy:Java实现手机APP安全卫士
很多计算机专业大学生经常和我交流:毕业设计没思路、不会做、论文不会写、太难了...... 针对这些问题,决定分享一些软、硬件项目的设计思路和实施方法,希望可以帮助大家,也祝愿各位学子,顺利毕业!
|
双11 Android开发
Android动态来改变App桌面图标
其实对于这样的一个桌面图标更换,Android中为我们提供了AndroidManifest.xml里的<activity-alias>标签实现方式。
326 0
|
存储 文字识别 JavaScript
毕业设计So Easy:Java MySQL智能报纸阅读器APP应用
很多计算机专业大学生经常和我交流:毕业设计没思路、不会做、论文不会写、太难了...... 针对这些问题,决定分享一些软、硬件项目的设计思路和实施方法,希望可以帮助大家,也祝愿各位学子,顺利毕业!
|
机器学习/深度学习 数据采集 NoSQL
毕业设计So Easy:卷积神经网络实现中药材识别系统APP
很多计算机专业大学生经常和我交流:毕业设计没思路、不会做、论文不会写、太难了...... 针对这些问题,决定分享一些软、硬件项目的设计思路和实施方法,希望可以帮助大家,也祝愿各位学子,顺利毕业!
|
移动开发 缓存 安全
App极限瘦身 | 动态下发so(2)
App极限瘦身 | 动态下发so
176 0
下一篇
无影云桌面