APM监控 · 入门篇 · Android端测监控平台建设(1)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: APM 全称 Application Performance Management & Monitoring (应用性能管理/监控)性能问题是导致 App 用户流失的罪魁祸首之一,如果用户在使用我们 App 的时候遇到诸如页面卡顿、响应速度慢、发热严重、流量电量消耗大等问题的时候,很可能就会卸载掉我们的 App。这也是我们在目前工作中面临的巨大挑战之一,尤其是低端机型。

1681523293361.png

改不完的 Bug,写不完的矫情。公众号 小木箱成长营 现在专注移动基础开发 ,涵盖音视频和 APM,信息安全等各个知识领域;只做全网最 Geek 的公众号,欢迎您的关注!

APM简要介绍

APM 全称 Application Performance Management & Monitoring (应用性能管理/监控)

性能问题是导致 App 用户流失的罪魁祸首之一,如果用户在使用我们 App 的时候遇到诸如页面卡顿、响应速度慢、发热严重、流量电量消耗大等问题的时候,很可能就会卸载掉我们的 App。这也是我们在目前工作中面临的巨大挑战之一,尤其是低端机型。

商业化的APM平台:著名的 NewRelic,还有国内的听云、OneAPM 、阿里百川-码力APM的SDK、百度的APM收费产品等等。

APM工作方式:

  1. 首先在客户端(Android、iOS、Web等)采集数据;
  2. 接着将采集到的数据整理上报到服务器(多种方式 json、xml,上传策略等等);
  3. 服务器接收到数据后建模、存储、挖掘分析,让后将数据进行可视化展示(spark+flink),供用户使用。
  4. 1681523455850.png
  5. 那么移动端需要做的事情就是:
  • 双端统一原则(技术选型 NDK c++)
  • 数据采集 (采集指标、细化等等)
  • 数据存储(写文件?mmap、fileIO流)
  • 数据上报(上报策略、上报方式)

那我们到底应该怎么做?一定要学会看开源的东西。让我们先看看大厂的开源怎么做的?我们在自己造轮子,完成自己的APM采集框架。

目前核心开源APM框架产品

你会发现自定义Gradle插件技术、ASM技术、打包流程Hook、Android打包流程等。那思考一下,为什么大家做的主要的流程都是一样的,不一样的是具体的实现细节,比如如何插桩采集到页面帧率、流量、耗电量、GC log等等。

ArgusAPM性能监控平台介绍&SDK开源-卜云涛.pdf

我们先简单来看下在matrix中,如何利用Java Hook和 Native Hook完成IO 磁盘性能的监控?

1681523548125.png

Java Hook的hook点是系统类CloseGuard,hook的方式是使用动态代理。

github.com/Tencent/mat…

private boolean tryHook() {
        try {
            Class<?> closeGuardCls = Class.forName("dalvik.system.CloseGuard");
            Class<?> closeGuardReporterCls = Class.forName("dalvik.system.CloseGuard$Reporter");
            Method methodGetReporter = closeGuardCls.getDeclaredMethod("getReporter");
            Method methodSetReporter = closeGuardCls.getDeclaredMethod("setReporter", closeGuardReporterCls);
            Method methodSetEnabled = closeGuardCls.getDeclaredMethod("setEnabled", boolean.class);
            sOriginalReporter = methodGetReporter.invoke(null);
            methodSetEnabled.invoke(null, true);
            // open matrix close guard also
            MatrixCloseGuard.setEnabled(true);
            ClassLoader classLoader = closeGuardReporterCls.getClassLoader();
            if (classLoader == null) {
                return false;
            }
            methodSetReporter.invoke(null, Proxy.newProxyInstance(classLoader,
                new Class<?>[]{closeGuardReporterCls},
                new IOCloseLeakDetector(issueListener, sOriginalReporter)));
            return true;
        } catch (Throwable e) {
            MatrixLog.e(TAG, "tryHook exp=%s", e);
        }
        return false;
    }

这里的CloseGuard有啥用?为什么腾讯的人要hook这个。这个在后续的分线中我们在来详细的说。如果要解决这个疑问,做好的办法就是看源码。(==系统埋点方式,监控系统资源的异常回收==)

关于native hook:

Native Hook是采用PLT(GOT) Hook的方式hook了系统so中的IO相关的openreadwriteclose方法。在代理了这些系统方法后,Matrix做了一些逻辑上的细分,从而检测出不同的IO Issue。

github.com/Tencent/mat…

 JNIEXPORT jboolean JNICALL
        Java_com_tencent_matrix_iocanary_core_IOCanaryJniBridge_doHook(JNIEnv *env, jclass type) {
            __android_log_print(ANDROID_LOG_INFO, kTag, "doHook");
            for (int i = 0; i < TARGET_MODULE_COUNT; ++i) {
                const char* so_name = TARGET_MODULES[i];
                __android_log_print(ANDROID_LOG_INFO, kTag, "try to hook function in %s.", so_name);
                //打开so文件,并在内存中映射成ELF文件格式
                loaded_soinfo* soinfo = elfhook_open(so_name);
                if (!soinfo) {
                    __android_log_print(ANDROID_LOG_WARN, kTag, "Failure to open %s, try next.", so_name);
                    continue;
                }
                //替换open函数
                elfhook_replace(soinfo, "open", (void*)ProxyOpen, (void**)&original_open);
                elfhook_replace(soinfo, "open64", (void*)ProxyOpen64, (void**)&original_open64);
                bool is_libjavacore = (strstr(so_name, "libjavacore.so") != nullptr);
                if (is_libjavacore) {
                    if (!elfhook_replace(soinfo, "read", (void*)ProxyRead, (void**)&original_read)) {
                        __android_log_print(ANDROID_LOG_WARN, kTag, "doHook hook read failed, try __read_chk");
                        //http://refspecs.linux-foundation.org/LSB_4.1.0/LSB-Core-generic/LSB-Core-generic/libc---read-chk-1.html 类似于read()
                        if (!elfhook_replace(soinfo, "__read_chk", (void*)ProxyRead, (void**)&original_read)) {
                            __android_log_print(ANDROID_LOG_WARN, kTag, "doHook hook failed: __read_chk");
                            elfhook_close(soinfo);
                            return false;
                        }
                    }
                    if (!elfhook_replace(soinfo, "write", (void*)ProxyWrite, (void**)&original_write)) {
                        __android_log_print(ANDROID_LOG_WARN, kTag, "doHook hook write failed, try __write_chk");
                        if (!elfhook_replace(soinfo, "__write_chk", (void*)ProxyWrite, (void**)&original_write)) {
                            __android_log_print(ANDROID_LOG_WARN, kTag, "doHook hook failed: __write_chk");
                            elfhook_close(soinfo);
                            return false;
                        }
                    }
                }
                elfhook_replace(soinfo, "close", (void*)ProxyClose, (void**)&original_close);
                elfhook_close(soinfo);
            }
            return true;
        }

关于transform api

google.github.io/android-gra…

我们编译Android项目时,如果我们想拿到编译时产生的Class文件,并在生成Dex之前做一些处理,我们可以通过编写一个Transform来接收这些输入(编译产生的Class文件),并向已经产生的输入中添加一些东西。

1681523668026.png

如何使用的?

github.com/Tencent/mat…

  • 编写一个自定义的Transform
  • 注册一个Plugin完成或者在gradle文件中直接注册。
//MyCustomPlgin.groovy
public class MyCustomPlgin implements Plugin<Project> {
    @Override
    public void apply(Project project) {
        project.getExtensions().findByType(BaseExtension.class)
                .registerTransform(new MyCustomTransform());
    }
}
project.extensions.findByType(BaseExtension.class).registerTransform(new MyCustomTransform());  //在build.gradle中直接写

MatrixTraceTransform 利用编译期字节码插桩技术,优化了移动端的FPS、卡顿、启动的检测手段。在打包过程中,hook生成Dex的Task任务,添加方法插桩的逻辑。我们的hook点是在Proguard之后,Class已经被混淆了,所以需要考虑类混淆的问题。

MatrixTraceTransform主要逻辑在transform方法中:

@Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        long start = System.currentTimeMillis()
        //是否增量编译
        final boolean isIncremental = transformInvocation.isIncremental() && this.isIncremental()
        //transform的结果,重定向输出到这个目录
        final File rootOutput = new File(project.matrix.output, "classes/${getName()}/")
        if (!rootOutput.exists()) {
            rootOutput.mkdirs()
        }
        final TraceBuildConfig traceConfig = initConfig()
        Log.i("Matrix." + getName(), "[transform] isIncremental:%s rootOutput:%s", isIncremental, rootOutput.getAbsolutePath())
        //获取Class混淆的mapping信息,存储到mappingCollector中
        final MappingCollector mappingCollector = new MappingCollector()
        File mappingFile = new File(traceConfig.getMappingPath());
        if (mappingFile.exists() && mappingFile.isFile()) {
            MappingReader mappingReader = new MappingReader(mappingFile);
            mappingReader.read(mappingCollector)
        }
        Map<File, File> jarInputMap = new HashMap<>()
        Map<File, File> scrInputMap = new HashMap<>()
        transformInvocation.inputs.each { TransformInput input ->
            input.directoryInputs.each { DirectoryInput dirInput ->
                //收集、重定向目录中的class
                collectAndIdentifyDir(scrInputMap, dirInput, rootOutput, isIncremental)
            }
            input.jarInputs.each { JarInput jarInput ->
                if (jarInput.getStatus() != Status.REMOVED) {
                    //收集、重定向jar包中的class
                    collectAndIdentifyJar(jarInputMap, scrInputMap, jarInput, rootOutput, isIncremental)
                }
            }
        }
        //收集需要插桩的方法信息,每个插桩信息封装成TraceMethod对象
        MethodCollector methodCollector = new MethodCollector(traceConfig, mappingCollector)
        HashMap<String, TraceMethod> collectedMethodMap = methodCollector.collect(scrInputMap.keySet().toList(), jarInputMap.keySet().toList())
       //执行插桩逻辑,在需要插桩方法的入口、出口添加MethodBeat的i/o逻辑
        MethodTracer methodTracer = new MethodTracer(traceConfig, collectedMethodMap, methodCollector.getCollectedClassExtendMap())
        methodTracer.trace(scrInputMap, jarInputMap)
        //执行原transform的逻辑;默认transformClassesWithDexBuilderForDebug这个task会将Class转换成Dex
        origTransform.transform(transformInvocation)
        Log.i("Matrix." + getName(), "[transform] cost time: %dms", System.currentTimeMillis() - start)
    }

看到这里了,我们是不是应该总结下APM的核心技术是什么?

大厂面试之一:APM的核心技术是什么?做过自研的APM吗?

APM核心原理一句话总结:

依据打包原理,在class转换为dex的过程中,调用gradletransformapi遍历class文件,借助JavassistASM等框架修改字节码,插入我们自己的代码实现性能数据的统计。这个过程是在编译器见完成

你掌握了APM的核心原理,也可以做Android的无痕埋点了,本质是一样的,不一样的是Hook的地方不一样。

APM监控维度和指标

App基础性能指标集中为8类:网络性能、崩溃、启动加载、内存、图片、页面渲染、IM和VoIP(业务相关性和你的APP相关)、用户行为监控,基础维度包括App、系统平台、App版本和时间维度。

网络性能

网络服务成功率,平均耗时和访问量、上下行的速率监控。访问的链接、耗时等等。思考怎么结合okhttp来做?

网络监控业务背景

在不确定哪个网络url耗时比较慢的情况下,做一个全链路网络监控体系,这个是我们值得深思的问题,这边使用AspectJ和利用OKhttp自身的EventListener对网络信息进行二次封装上报

网络监控实现步骤

第一步: 将我们需要监控的数据源转换成 Bundle 对象方便传输
public interface BundleMapping {
    /**
     * 转换数据结构为Bundle
     */
    Bundle asBundle();
}
第二步: 确定我们需要监控的字段
字段 字段含义 备注
total 总网络时间 调用结束时间 减去 请求开始时间
pathname 请求地址 /
dns dnsEndTime - dnsStartTime DNS查询结束时间 减去 DNS查询开始时间
protocol 请求协议 /
tcp 连接结束时间 调用结束时间 减去 连接开始时间
no_dns_tcp_tls 总网络时间 调用结束时间 减去 请求开始时间
tls 总网络时间 调用结束时间 减去 请求开始时间
no_response 是否无响应内容(304或返回body为空) /
ttfb ttfb首字节时间 响应结束时间 减去 请求开始时间
download 总网络时间 响应结束时间 减去 响应开始时间
pure_network 总网络时间 响应结束时间 减去 响应开始时间
transfer_size 传输大小 /
failed 请求失败信息 /
public class NetWorkData implements BundleMapping {
    //请求地址
    String url;
    //请求协议
    String protocol;
    //请求开始时间
    long callStartTime;
    //响应开始时间
    long responseStartTime;
    //响应结束时间
    long responseEndTime;
    //DNS查询开始时间
    long dnsStartTime;
    //DNS查询结束时间
    long dnsEndTime;
    //连接开始时间
    long connectStartTime;
    //连接结束时间
    long connectEndTime;
    //是否同时重用DNS、TCP、TLS
    boolean isDnsTcpTls;
    //SSL 连接开始时间
    long secureConnectStartTime;
    //SSL 连接结束时间
    long secureConnectEndTime;
    //是否无响应内容(304或返回body为空)
    boolean isNoResponse;
    //调用结束时间
    long callEndTime;
    //请求失败信息
    String failMessage;
    //传输大小
    long byteCount;
    @Override
    public Bundle asBundle() {
        Bundle bundle = new Bundle();
        //总网络时间
        bundle.putLong("total", callEndTime - callStartTime);
        bundle.putString("pathname", url);
        //dns
        bundle.putLong("dns", dnsEndTime - dnsStartTime);
        bundle.putString("protocol", protocol);
        //tcp
        bundle.putLong("tcp", connectEndTime - connectStartTime);
        bundle.putBoolean("no_dns_tcp_tls", isDnsTcpTls);
        //tls
        bundle.putLong("tls", secureConnectEndTime - secureConnectStartTime);
        bundle.putBoolean("no_response", isNoResponse);
        //ttfb首字节时间
        bundle.putLong("ttfb", responseEndTime - callStartTime);
        //download
        bundle.putLong("download", responseEndTime - responseStartTime);
        //pure_network
        bundle.putLong("pure_network", responseEndTime - callStartTime);
        //transfer_size
        bundle.putLong("transfer_size", byteCount);
        //连接失败
        bundle.putString("failed", failMessage);
        return bundle;
    }
}
第三步: 添加我们需要上报的标记,根据不同的域名去区分
    /**
     * 获取上报Tag
     */
    String getDataTag() {
        String tag;
        if (url == null) {
            tag = "biz";
        } else {
            if (url.contains("microkibaco_report")) {
                tag = "data";
            } else if (url.contains("blog")) {
                tag = "blog";
            } else {
                tag = "github";
            }
        }
        return "network_api_" + tag;
    }
第五步: 添加沪江aspectj插件
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.6'
    implementation 'org.aspectj:aspectjrt:1.8.9'
    implementation "com.squareup.okhttp3:okhttp:3.12.1"
第六步: 给OkHttp 添加事件监听
  @Keep
  public class MkOkHttpEventListener extends EventListener {
      private NetWorkData mNetWorkData;
      public static final Factory FACTORY = new Factory() {
          @Override
          public EventListener create(Call call) {
              return new MkOkHttpEventListener();
          }
      };
      /**
       * 每个请求都会构建
       */
      private MkOkHttpEventListener() {
          mNetWorkData = new NetWorkData();
      }
      @Override
      public void callStart(Call call) {
          mNetWorkData.url = call.request().url().toString();
          mNetWorkData.callStartTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void connectStart(Call call, InetSocketAddress inetSocketAddress, Proxy proxy) {
          mNetWorkData.connectStartTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void connectEnd(Call call, InetSocketAddress inetSocketAddress, Proxy proxy,
              Protocol protocol) {
          mNetWorkData.connectEndTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void connectFailed(Call call, InetSocketAddress inetSocketAddress, Proxy proxy,
              Protocol protocol, IOException ioe) {
      }
      @Override
      public void dnsStart(Call call, String domainName) {
          mNetWorkData.dnsStartTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void dnsEnd(Call call, String domainName, List<InetAddress> inetAddressList) {
          mNetWorkData.dnsEndTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void secureConnectStart(Call call) {
          mNetWorkData.secureConnectStartTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void secureConnectEnd(Call call, Handshake handshake) {
          mNetWorkData.secureConnectEndTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void responseHeadersStart(Call call) {
          mNetWorkData.responseStartTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void responseHeadersEnd(Call call, Response response) {
          if (response.code() == 304) {
              mNetWorkData.isNoResponse = true;
          }
          mNetWorkData.protocol = response.protocol().toString();
          mNetWorkData.responseEndTime = SystemClock.elapsedRealtime();
      }
      @Override
      public void responseBodyEnd(Call call, long byteCount) {
          //响应为空
          if (byteCount == 0) {
              mNetWorkData.isNoResponse = true;
          }
          mNetWorkData.byteCount = byteCount;
      }
      @Override
      public void callEnd(Call call) {
          mNetWorkData.callEndTime = SystemClock.elapsedRealtime();
          //上报
          APM.getReportStrategy().report(mNetWorkData.getDataTag(), mNetWorkData.asBundle());
      }
      @Override
      public void callFailed(Call call, IOException ex) {
          mNetWorkData.failMessage = ex.getMessage();
          //上报
          APM.getReportStrategy().report(mNetWorkData.getDataTag(), mNetWorkData.asBundle());
      }
  }

Firebase 有一定的时效性,所以我们把日志全部采集到Json里面交给Bundle统一上报

崩溃

崩溃数据的采集和分析,类似于Bugly平台的功能

启动加载

App的启动我们做了大的力气进行了优化,多线程等等, Spark的有向无环图(DAG)来处理业务的依赖性。对App的冷启动时长、Android安装后首次启动时长和Android Bundle(atlas框架)启动加载时长进行监控。

内存

四大监测目标:内存峰值、内存均值、内存抖动、内存泄露。

IM和VoIP等业务指标

这两项都属于业务型技术指标的监控,例如对各类IM消息到达率和VoIP通话的成功率、平均耗时和请求量进行监控。这里需要根据自己的APP的业务进行针对性的梳理。

用户行为监控

用于App统计用户行为,实际上就是监控所有事件并把事件发送到服务上去。这在以前是埋点做的事情,现在也规整成APM需要做的事情,比如用户的访问路径,类似于PC时代的PV,UV等概念。

图片

资源文件的监测,比如Bitmap冗余处理。haha库处理,索引值。

页面渲染

界面流畅性监测、FPS的监测、慢函数监测、卡顿监测、文件IO开销监测等导致页面渲染的各种问题。

相关实践学习
通过云拨测对指定服务器进行Ping/DNS监测
本实验将通过云拨测对指定服务器进行Ping/DNS监测,评估网站服务质量和用户体验。
相关文章
|
2月前
|
IDE 开发工具 Android开发
移动应用开发之旅:探索Android和iOS平台
在这篇文章中,我们将深入探讨移动应用开发的两个主要平台——Android和iOS。我们将了解它们的操作系统、开发环境和工具,并通过代码示例展示如何在这两个平台上创建一个简单的“Hello World”应用。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的信息和技巧,帮助你更好地理解和掌握移动应用开发。
94 17
|
3月前
|
XML 数据库 Android开发
探索Android开发:从入门到精通的旅程
在这篇文章中,我们将一起踏上一段激动人心的旅程,通过深入浅出的方式,解锁Android开发的秘密。无论你是编程新手还是有经验的开发者,本文都将为你提供宝贵的知识和技能,帮助你构建出色的Android应用。我们将从基础概念开始,逐步深入到高级技巧和最佳实践,最终实现从初学者到专家的转变。让我们开始吧!
73 3
|
3月前
|
XML IDE Java
安卓应用开发入门:从零开始的旅程
【10月更文挑战第23天】本文将带领读者开启一段安卓应用开发的奇妙之旅。我们将从最基础的概念讲起,逐步深入到开发实践,最后通过一个简易的代码示例,展示如何将理论知识转化为实际的应用。无论你是编程新手,还是希望扩展技能的软件工程师,这篇文章都将为你提供有价值的指导和启发。
63 0
|
4月前
|
存储 前端开发 测试技术
Android kotlin MVVM 架构简单示例入门
Android kotlin MVVM 架构简单示例入门
63 1
|
4月前
|
Linux API 开发工具
FFmpeg开发笔记(五十九)Linux编译ijkplayer的Android平台so库
ijkplayer是由B站研发的移动端播放器,基于FFmpeg 3.4,支持Android和iOS。其源码托管于GitHub,截至2024年9月15日,获得了3.24万星标和0.81万分支,尽管已停止更新6年。本文档介绍了如何在Linux环境下编译ijkplayer的so库,以便在较新的开发环境中使用。首先需安装编译工具并调整/tmp分区大小,接着下载并安装Android SDK和NDK,最后下载ijkplayer源码并编译。详细步骤包括环境准备、工具安装及库编译等。更多FFmpeg开发知识可参考相关书籍。
145 0
FFmpeg开发笔记(五十九)Linux编译ijkplayer的Android平台so库
|
4月前
|
Web App开发 编解码 视频直播
视频直播技术干货(十二):从入门到放弃,快速学习Android端直播技术
本文详细介绍了Android端直播技术的全貌,涵盖了从实时音视频采集、编码、传输到解码与播放的各个环节。文章还探讨了直播中音视频同步、编解码器选择、传输协议以及直播延迟优化等关键问题。希望本文能为你提供有关Andriod端直播技术的深入理解和实践指导。
90 0
|
4月前
|
Java Android开发 Swift
安卓与iOS开发对比:平台选择对项目成功的影响
【10月更文挑战第4天】在移动应用开发的世界中,选择合适的平台是至关重要的。本文将深入探讨安卓和iOS两大主流平台的开发环境、用户基础、市场份额和开发成本等方面的差异,并分析这些差异如何影响项目的最终成果。通过比较这两个平台的优势与挑战,开发者可以更好地决定哪个平台更适合他们的项目需求。
146 1
|
4月前
|
移动开发 监控 Android开发
Android & iOS 使用 ARMS 用户体验监控(RUM)的最佳实践
本文主要介绍了 ARMS 用户体验监控的基本功能特性,并介绍了在几种常见场景下的最佳实践。
556 21
|
4月前
|
开发框架 移动开发 Android开发
安卓与iOS开发中的跨平台解决方案:Flutter入门
【9月更文挑战第30天】在移动应用开发的广阔舞台上,安卓和iOS两大操作系统各自占据半壁江山。开发者们常常面临着选择:是专注于单一平台深耕细作,还是寻找一种能够横跨两大系统的开发方案?Flutter,作为一种新兴的跨平台UI工具包,正以其现代、响应式的特点赢得开发者的青睐。本文将带你一探究竟,从Flutter的基础概念到实战应用,深入浅出地介绍这一技术的魅力所在。
139 7
|
5月前
|
监控 安全 Java
Kotlin 在公司上网监控中的安卓开发应用
在数字化办公环境中,公司对员工上网行为的监控日益重要。Kotlin 作为一种基于 JVM 的编程语言,具备简洁、安全、高效的特性,已成为安卓开发的首选语言之一。通过网络请求拦截,Kotlin 可实现网址监控、访问时间记录等功能,满足公司上网监控需求。其简洁性有助于快速构建强大的监控应用,并便于后续维护与扩展。因此,Kotlin 在安卓上网监控应用开发中展现出广阔前景。
41 1

热门文章

最新文章

  • 1
    ARMS:端到端全链路,应用可观测再进化
  • 2
    如何修复 Android 和 Windows 不支持视频编解码器的问题?
  • 3
    Android历史版本与APK文件结构
  • 4
    【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
  • 5
    【04】flutter补打包流程的签名过程-APP安卓调试配置-结构化项目目录-完善注册相关页面-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程
  • 6
    当flutter react native 等混开框架-并且用vscode-idea等编译器无法打包apk,打包安卓不成功怎么办-直接用android studio如何打包安卓apk -重要-优雅草卓伊凡
  • 7
    APP-国内主流安卓商店-应用市场-鸿蒙商店上架之必备前提·全国公安安全信息评估报告如何申请-需要安全评估报告的资料是哪些-优雅草卓伊凡全程操作
  • 8
    【09】flutter首页进行了完善-采用android studio 进行真机调试开发-增加了直播间列表和短视频人物列表-增加了用户中心-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
  • 9
    Android经典面试题之Kotlin中Lambda表达式和匿名函数的区别
  • 10
    【02】仿站技术之python技术,看完学会再也不用去购买收费工具了-本次找了小影-感觉页面很好看-本次是爬取vue需要用到Puppeteer库用node.js扒一个app下载落地页-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-优雅草卓伊凡
  • 1
    Cellebrite UFED 4PC 7.71 (Windows) - Android 和 iOS 移动设备取证软件
    24
  • 2
    【03】仿站技术之python技术,看完学会再也不用去购买收费工具了-修改整体页面做好安卓下载发给客户-并且开始提交网站公安备案-作为APP下载落地页文娱产品一定要备案-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-优雅草卓伊凡
    33
  • 3
    Android历史版本与APK文件结构
    121
  • 4
    【02】仿站技术之python技术,看完学会再也不用去购买收费工具了-本次找了小影-感觉页面很好看-本次是爬取vue需要用到Puppeteer库用node.js扒一个app下载落地页-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-优雅草卓伊凡
    28
  • 5
    【01】仿站技术之python技术,看完学会再也不用去购买收费工具了-用python扒一个app下载落地页-包括安卓android下载(简单)-ios苹果plist下载(稍微麻烦一丢丢)-客户的麻将软件需要下载落地页并且要做搜索引擎推广-本文用python语言快速开发爬取落地页下载-优雅草卓伊凡
    23
  • 6
    APP-国内主流安卓商店-应用市场-鸿蒙商店上架之必备前提·全国公安安全信息评估报告如何申请-需要安全评估报告的资料是哪些-优雅草卓伊凡全程操作
    57
  • 7
    【09】flutter首页进行了完善-采用android studio 进行真机调试开发-增加了直播间列表和短视频人物列表-增加了用户中心-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
    37
  • 8
    当flutter react native 等混开框架-并且用vscode-idea等编译器无法打包apk,打包安卓不成功怎么办-直接用android studio如何打包安卓apk -重要-优雅草卓伊凡
    73
  • 9
    【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
    118
  • 10
    Android经典面试题之Kotlin中Lambda表达式和匿名函数的区别
    29