阅读 5891

深入探索 Android 网络优化(三、网络优化篇)下

前言

成为一名优秀的Android开发,需要一份完备的知识体系,在这里,让我们一起成长为自己所想的那样~。

本文思维导图

五、网络请求质量优化(🔥)

1、Http 请求过程

  • 1)、请求到达运营商的 DNS 服务器并* 解析* 成对应的 IP 地址。
    • HTTPDNS
  • 2)、根据 IP 地址找到相应的服务器,进行 TCP 三次握手,创建连接
    • 连接复用
    • 网络库的连接管理
  • 3)、发送/接收数据。
    • 压缩
    • 加密
  • 4)、关闭连接。

2、HTTPDNS

问题:DNS 解析慢/被劫持?

使用 HTTPDSN,HTTPDNS 不是使用 DNS 协议,向 DNS 服务器传统的 53 端口发送请求,而是使用 HTTP 协议向 DSN 服务器的 80 端口发送请求。

1)、HTTPDNS 优势

  • 1、绕过运营商域名解析的过程,避免 Local DNS 的劫持。
  • 2、降低平均访问时延,提供连接成功率。
  • 3、HTTPDNS 服务器会增加流量调度、网络拨测/灰度、网络容灾等功能。

2)、HTTPDNS + OKHttp 实践

在 Awesome-WanAndroid 中已经实现了 HTTPDNS 优化,其优化代码如下所示:

// HttpModule-provideClient:httpDns 优化
builder.dns(OkHttpDns.getIns(WanAndroidApp.getAppComponent().getContext()));

/**
 * FileName: OkHttpDNS
 * Date: 2020/5/8 16:08
 * Description: HttpDns 优化
 * @author JsonChao
 */
public class OkHttpDns implements Dns {

    private HttpDnsService dnsService;
    private static OkHttpDns instance = null;

    private OkHttpDns(Context context) {
        dnsService = HttpDns.getService(context, "161133");
        // 1、设置预解析的 IP 使用 Https 请求。
        dnsService.setHTTPSRequestEnabled(true);
        // 2、预先注册要使用到的域名,以便 SDK 提前解析,减少后续解析域名时请求的时延。
        ArrayList<String> hostList = new ArrayList<>(Arrays.asList("www.wanandroid.com"));
        dnsService.setPreResolveHosts(hostList);
    }

    public static OkHttpDns getIns(Context context) {
        if (instance == null) {
            synchronized (OkHttpDns.class) {
                if (instance == null) {
                    instance = new OkHttpDns(context);
                }
            }
        }
        return instance;
    }

    @Override
    public List<InetAddress> lookup(String hostname) throws UnknownHostException {
        String ip = dnsService.getIpByHostAsync(hostname);
        LogHelper.i("httpDns: " + ip);
        if(ip != null){
            List<InetAddress> inetAddresses = Arrays.asList(InetAddress.getAllByName(ip));
            return inetAddresses;
        }
        // 3、如果从阿里云 DNS 服务器获取不到 ip 地址,则走运营商域名解析的过程。
        return Dns.SYSTEM.lookup(hostname);
    }
}
复制代码

重新安装 App,通过 HTTPDNS 获取到 IP 地址 log 如下所示:

2020-05-11 10:41:55.139 4036-4184/json.chao.com.wanandroid I/WanAndroid-LOG: │ [OkHttpDns.java | 52 | lookup] httpDns: 47.104.74.169
2020-05-11 10:41:55.142 4036-4185/json.chao.com.wanandroid I/WanAndroid-LOG: │ [OkHttpDns.java | 52 | lookup] httpDns: 47.104.74.169
复制代码

3、网络库的连接管理

利用 HTTP 协议的 keep-alive,建立连接后,会先将连接放入连接池中,如果有另一个请求的域名和端口是一样的,就直接使用连接池中对应的连接发送和接收数据。在实现网络库的连接管理时需要注意以下4点:

  • 1)、同一个连接仅支持同一个域名。
  • 2)、后端支持 HTTP 2.0 需要改造,这里可以通过在网络平台的统一接入层将数据转换到 HTTP 1.1 后再转发到对应域名的服务器即可。
  • 3)、当所有请求都集中在一条连接中时,在网络拥塞时容易出现 TCP 队首阻塞问题。
  • 4)、在文件下载、视频播放等场景下可能会遇到三方服务器单连接限速的问题,此时可以禁用 HTTP 2.0。

4、协议版本升级

HTTP 1.0

TCP 连接不复用,也就是每发起一个网络请求都要重新建立连接,而刚开始连接都会经历一个慢启动的过程,可谓是慢上加慢,因此 HTTP 1.0 性能非常差。

HTTP 1.1

引入了持久连接,即 TCP 连接可以复用,但数据通信必须按次序来,也就是后面的请求必须等前面的请求完成才能进行。当所有请求都集中在一条连接中时,在网络拥塞时容易出现 TCP 队首阻塞问题。

HTTP 2

  • 二进制协议
  • 多工
  • 服务端与客户端可以双向实时通信。

QUIC

Google 2013 实现,2018 基于 QUIC 协议的 HTTP 被确认为 HTTP3。

QUIC 简单理解为 HTTP/2.0 + TLS 1.3 + UDP。弱网环境下表现好与 TCP。

优势

  • 1)、解决了在连接复用中 HTTP2 + TCP 存在的队首阻塞问题,
  • 2)、由于是基于 UDP,所以可以灵活控制拥塞协议。例如 Client 端可以直接使用 Google 的 BBR 算法
  • 3)、连接迁:由于 UDP 通过类似connection id 的特性,使得客户端网络切换的时候不需要重连,用户使用 App 的体验会更加流畅。

目前的缺点

  • 1)、NAT 局域网路由、交换机、防火墙等会禁止 UDP 443 通行,因此 QUIC 创建连接成功率只有95%。
  • 2)、运营商针对 UDP 通道不支持/支持不足。
  • 3)、使用 UDP 不一定会比 TCP 更快,客户端可同时使用 TCP 和 QUIC 竞速,从而选择更优链路。

使用场景

  • 1)、实时性
  • 2)、可丢弃
  • 3)、请求互相依赖
  • 4)、可同时使用 TCP & QUIC

QUIC 加密协议原理

  • 1)、当 Client 与 Server 第一次通信时,会发送 Inchoate Client Hello 消息下载 Server Config(SCFG) 暂存消息。
  • 2)、SCFG 中包含一个 Diffie-Hellman 共享,下一次 Client 将使用它派生初始密钥(即 0-RTT 密钥)并利用其加密数据给 Server。
  • 3)、之后,Server 将发出一个新的暂存 Diffie-Hellman 共享,并由此派生出一组 前向安全密钥去进行数据的加密通信。

5、网络请求质量监控

1)、接口请求耗时、成功率、错误码

在 Awesome-WanAndroid 中已经使用 OkHttpEventListener 实现了网络请求的质量监控,其代码如下所示:

// 网络请求质量监控
builder.eventListenerFactory(OkHttpEventListener.FACTORY);

/**
 * FileName: OkHttpEventListener
 * Date: 2020/5/8 16:28
 * Description: OkHttp 网络请求质量监控
 * @author quchao
 */
public class OkHttpEventListener extends EventListener {

    public static final Factory FACTORY = new Factory() {
        @Override
        public EventListener create(Call call) {
            return new OkHttpEventListener();
        }
    };

    OkHttpEvent okHttpEvent;
    public OkHttpEventListener() {
        super();
        okHttpEvent = new OkHttpEvent();
    }

    @Override
    public void callStart(Call call) {
        super.callStart(call);
        LogHelper.i("okHttp Call Start");
        okHttpEvent.callStartTime = System.currentTimeMillis();
    }

    /**
     * DNS 解析开始
     *
     * @param call
     * @param domainName
     */
    @Override
    public void dnsStart(Call call, String domainName) {
        super.dnsStart(call, domainName);
        okHttpEvent.dnsStartTime = System.currentTimeMillis();
    }

    /**
     * DNS 解析结束
     *
     * @param call
     * @param domainName
     * @param inetAddressList
     */
    @Override
    public void dnsEnd(Call call, String domainName, List<InetAddress> inetAddressList) {
        super.dnsEnd(call, domainName, inetAddressList);
        okHttpEvent.dnsEndTime = System.currentTimeMillis();
    }

    @Override
    public void connectStart(Call call, InetSocketAddress inetSocketAddress, Proxy proxy) {
        super.connectStart(call, inetSocketAddress, proxy);
        okHttpEvent.connectStartTime = System.currentTimeMillis();
    }

    @Override
    public void secureConnectStart(Call call) {
        super.secureConnectStart(call);
        okHttpEvent.secureConnectStart = System.currentTimeMillis();
    }

    @Override
    public void secureConnectEnd(Call call, @Nullable Handshake handshake) {
        super.secureConnectEnd(call, handshake);
        okHttpEvent.secureConnectEnd = System.currentTimeMillis();
    }

    @Override
    public void connectEnd(Call call, InetSocketAddress inetSocketAddress, Proxy proxy, @Nullable Protocol protocol) {
        super.connectEnd(call, inetSocketAddress, proxy, protocol);
        okHttpEvent.connectEndTime = System.currentTimeMillis();
    }

    @Override
    public void connectFailed(Call call, InetSocketAddress inetSocketAddress, Proxy proxy, @Nullable Protocol protocol, IOException ioe) {
        super.connectFailed(call, inetSocketAddress, proxy, protocol, ioe);
    }

    @Override
    public void connectionAcquired(Call call, Connection connection) {
        super.connectionAcquired(call, connection);
    }

    @Override
    public void connectionReleased(Call call, Connection connection) {
        super.connectionReleased(call, connection);
    }

    @Override
    public void requestHeadersStart(Call call) {
        super.requestHeadersStart(call);
    }

    @Override
    public void requestHeadersEnd(Call call, Request request) {
        super.requestHeadersEnd(call, request);
    }

    @Override
    public void requestBodyStart(Call call) {
        super.requestBodyStart(call);
    }

    @Override
    public void requestBodyEnd(Call call, long byteCount) {
        super.requestBodyEnd(call, byteCount);
    }

    @Override
    public void responseHeadersStart(Call call) {
        super.responseHeadersStart(call);
    }

    @Override
    public void responseHeadersEnd(Call call, Response response) {
        super.responseHeadersEnd(call, response);
    }

    @Override
    public void responseBodyStart(Call call) {
        super.responseBodyStart(call);
    }

    @Override
    public void responseBodyEnd(Call call, long byteCount) {
        super.responseBodyEnd(call, byteCount);
        // 记录响应体的大小
        okHttpEvent.responseBodySize = byteCount;
    }

    @Override
    public void callEnd(Call call) {
        super.callEnd(call);
        okHttpEvent.callEndTime = System.currentTimeMillis();
        // 记录 API 请求成功
        okHttpEvent.apiSuccess = true;
        LogHelper.i(okHttpEvent.toString());
    }

    @Override
    public void callFailed(Call call, IOException ioe) {
        LogHelper.i("callFailed ");
        super.callFailed(call, ioe);
        // 记录 API 请求失败及原因
        okHttpEvent.apiSuccess = false;
        okHttpEvent.errorReason = Log.getStackTraceString(ioe);
        LogHelper.i("reason " + okHttpEvent.errorReason);
        LogHelper.i(okHttpEvent.toString());
    }
}
复制代码

成功 log 如下所示:

2020-05-11 11:00:42.678 6682-6847/json.chao.com.wanandroid D/OkHttp: --> GET https://www.wanandroid.com/banner/json
2020-05-11 11:00:42.687 6682-6848/json.chao.com.wanandroid I/WanAndroid-LOG: ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────
2020-05-11 11:00:42.688 6682-6848/json.chao.com.wanandroid I/WanAndroid-LOG: │ Thread: RxCachedThreadScheduler-3
2020-05-11 11:00:42.688 6682-6848/json.chao.com.wanandroid I/WanAndroid-LOG: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
2020-05-11 11:00:42.688 6682-6848/json.chao.com.wanandroid I/WanAndroid-LOG: │ OkHttpEventListener.callStart  (OkHttpEventListener.java:46)
2020-05-11 11:00:42.688 6682-6848/json.chao.com.wanandroid I/WanAndroid-LOG: │    LogHelper.i  (LogHelper.java:37)
2020-05-11 11:00:42.688 6682-6848/json.chao.com.wanandroid I/WanAndroid-LOG: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
2020-05-11 11:00:42.688 6682-6848/json.chao.com.wanandroid I/WanAndroid-LOG: │ [OkHttpEventListener.java | 46 | callStart] okHttp Call Start
2020-05-11 11:00:42.688 6682-6848/json.chao.com.wanandroid I/WanAndroid-LOG: └────────────────────────────────────────────────────────────────────────────────────────────────────────────────
2020-05-11 11:00:43.485 6682-6847/json.chao.com.wanandroid D/OkHttp: <-- 200 OK https://www.wanandroid.com/banner/json (806ms, unknown-length body)
2020-05-11 11:00:43.496 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ Thread: RxCachedThreadScheduler-2
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ OkHttpEventListener.callEnd  (OkHttpEventListener.java:162)
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │    LogHelper.i  (LogHelper.java:37)
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ [OkHttpEventListener.java | 162 | callEnd] NetData: [
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ callTime: 817
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ dnsParseTime: 6
2020-05-11 11:00:43.498 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ connectTime: 721
2020-05-11 11:00:43.499 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ secureConnectTime: 269
2020-05-11 11:00:43.499 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ responseBodySize: 975
2020-05-11 11:00:43.499 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ apiSuccess: true
2020-05-11 11:00:43.499 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: │ ]
2020-05-11 11:00:43.499 6682-6847/json.chao.com.wanandroid I/WanAndroid-LOG: └────────────────────────────────────────────────────────────────────────────────────────────────────────────────
复制代码

2)、根据网络质量来动态设定网络服务的重要参数(超时、并发线程数)

  • 根据用户 2G/3G/4G/WIFI 的网络环境。
  • 根据用户当前网络的 RTT。

6、压缩

1)、header(HTTP 2.0 头部压缩)

深入探索 Android 网络优化(二、网络优化基础篇)下 - 首部压缩

2)、URL

不变参数客户端只需上传以此,其它参数均在接入层进行扩展。

3)、body

使用 Protocol Buffers 替代 JSON 序列化。

4)、图片

  • 1)、webp
  • 2)、hevc
  • 3)、SharpP
  • 4)、基于 AI 的图片超清化
    • 深度学习 CNN(Convolutional Neural Network,卷积神经网络)。
    • CNN 学习到的是高分辨率图像和低分辨率图像的差。

5)、压缩算法

  • 1)、GZIP
  • 2)、Google Brotli
  • 3)、Facebook Z-standard(推荐):通过业务数据样本训练处合适的字典,因此是压缩率最好的算法,由于各业务线维护字典成本较大,可以在网络平台的统一接入层进行压缩与解压。我们可以抽样1%的数据来训练字典,而字典的下发与更新由统一接入层负责。

7、加密

HTTPS 通常需要多消耗 2 RTT 的协商时延。

1)、HTTPS 优化

1、提高连接复用率

  • 1)、多个域名共用同一个 HTTP2 连接。
  • 2)、长连接。

2、减少握手次数(TLS 1.3 实现 0 RTT 协商)

TLS 1.2 引入了 SHA-256 哈希算法,摒弃了 SHA-1,对增强数据完整性有着显著优势。

IETF(Internet Engineering Task Froce,互联网工程任务组)制定的 TLS 1.3 是有史以来最安全、复杂的 TLS 协议。它具有如下特点:

1)、更快的访问速度

相比于 TLS 1.2 及之前的版本,TLS 1.3 的握手不再支持静态的 RSA 密钥交换,使用的是带有前向安全的 Diffie-Hellman 进行全面握手。因此 TLS 1.3 只需 1-RTT 握手时间。

2)、更强的安全性

删除了之前版本的不安全的加密算法。

  • 1)、RSA 密钥传输:不支持前向安全性。
  • 2)、CBC 模式密码:易受 BEAST 和 Lucky 13 攻击。
  • 3)、RC4 流密码:在 HTTPS 中使用并不安全。
  • 4)、SHA-1 哈希函数:建议以 SHA-2 替代。
  • 5)、任意 Diffie-Hellman 组:CVE-2016-0701 漏洞。
  • 6)、输出密码:易受 FREAK 和 LogJam 攻击。

此外,我们可以在 Google 浏览器设置 TLS 1.3

3、slight-ssl

参考 TLS 1.3 协议,合并请求,优化加密算法,使用 session-ticket 等策略,力求在安全和体验间找到一个平衡点。

在 TLS 中性能开销最大的是 TLS 握手阶段的 RSA 加解密。在 slight-ssl 中又尝试如下几种解决方案:

  • 1)、硬件加速:使用单独的硬件加速卡处理 RSA 加解密。
  • 2)、ECDSA:ECSDSA 最底层的算法和成本对性能的消耗远低于 RSA,相差5~6倍。
  • 3)、Session Ticket 机制:将 TLS 握手从 2RTT 降低为 1RTT。

4、微信 mmtls 原理

基于 TLS 1.3 草案标准而实现。

类似于 TLS 协议,mmtls 协议也是位于业务层与网络连接层中间。

mmtls 协议组成图

  • 1)、Handshake、Alert 和 Application Protocol 都是 record 协议的上层协议。
  • 2)、Record 协议包中有字段用于区分器上层协议是上述3种任一协议。
  • 3)、在 mmtls/TLS 中Handshake 子协议负责密钥协商, Record 子协议负责数据对称加密传输。除了性能与效率的因素之外,更利于隔离复杂性。
Handshake 协议

TLS 1.3 Handshake 协议有如下几类:

  • 1-RTT 密钥协商方式
    • 1-RTT ECDHE
    • 1-RTT PSK(Pre-Shared Key)
  • 0-RTT 密钥协商方式
    • 0-RTT PSK
    • 0-RTT ECDH
    • 0-RTT PSK-ECDHE
    • 0-RTT ECDH-ECDHE

而 mmtls Handshake 协议有如下几种:

  • 1-RTT ECDHE
  • 1-RTT PSK
  • 0-RTT PSK

1-RTT ECDHE 密钥协商原理

ECDH 密钥交换协议需要使用两个算法:

  • 1)、密钥生成算法 ECDH_Generate_Key:生成公私钥对(ECDH_pub_key、ECDH_pri_key),其中保存私钥,将公钥互相发送给对方。
  • 2)、密钥协商算法 ECDH_compute_key:输入对方公钥与自身私钥,计算出通信双方一致的对称密钥 Key。

但是 1-RTT ECDHE 算法容易被中间人攻击,中间人可以截获双方的公钥运行 ECDH_Generate_key 生成自己的公私钥对,然后将公钥发送给某一方。

如何解决中间人攻击?

中间人攻击产生的本质原因是没有经过端点认证,需要”带认证的密钥协商“。

数据认证的方式?

数据认证有对称与非对称两种方式:

  • 1)、基于 MAC(Message Authentication Code,消息认证码)的对称认证
  • 2)、基于签名算法的非对称认证。

ECDH 认证密钥协商就是 ECDH 密钥协商 + 数字签名算法 ECDSA。

双方密钥协商会对自身发出的公钥使用签名算法,由于签名算法中的公钥 ECDSA_verify_key 是公开的,中间人没有办法阻止别人获取公钥。

而 mmtls 仅对 Server 做认证,因为通信一方签名其协商数据就不会被中间人攻击。

在 TLS 中,提供了可选的双方相互认证的能力:

  • Client 通过选择 CipherSuite 是什么类型来决定是否要对 Server 进行认证。
  • Server 通过是否发送 CertificateRequest 握手消息来决定是否要对 Client 进行认证。

1-RTT PSK 密钥协商原理

在之前的 ECDH 握手下,Server 会下发加密的 PSK{key, ticket{key}},其中:

  • key:用来做对称加密密钥的 key 明文。
  • ticket{key}:用 server 私密保存的 ticket_key 对 key 进行加密的密文 ticket。

1)、首先,Client 将 ticket{key}、Client_Random 发送给 Server。

2)、然后,Server 使用 ticket_key 解密得到 key、Server_Random、Client_Random 计算 MAC 来认证。

3)、最后,Server 将 Server_Random、MAC 发送给 Client,Client 同 Server 使用 ticket_key 解密得到 key、Server_Random、Client_Random 去计算 MAC 来验证是否与收到的 MAC 匹配。

0-RTT ECDH 密钥协商原理

要想实现 0-RTT 密钥协商,就必须在协商一开始就将业务数据安全地传递到对端。

预先生成一对公私钥(static_svr_pub_key, static_svr_pri_key),并将公钥预置在 Client,私钥持久保存在 Server。

1)、首先,Client 通过 static_svr_pub_key 与 cli_pri_key 生成一个对称密钥SS(Static Secret),用 SS 衍生的密钥对业务数据加密。

2)、然后,Client cli_pub_key、Client_Random、SS 加密的 AppData 发送给 Server,Sever 通过 cli_pub_key 和 static_svr_pri_key 算出 SS,解密业务数据包。

1-RTT PSK 密钥协商原理

在进行 1-RTT PSK 握手之前,Client 已经有一个对称加密密钥 key 了,直接使用此 key 与 ticket{key} 一起传递给 Server 即可。

TLS 1.3 为什么要废除 RSA?

  • 1)、2015年发现了 FREAK 攻击,出现了 RSA 漏洞。
  • 2)、一旦私钥泄露,中间人就可以通过私钥计算出之前所有报文的密钥,破解之前所有的密文。

因此 TLS 1.3 引入了 PFS(perfect forward secrecy,前向安全性),即完全向前保密,一个密钥被破解,并不会影响其它密钥的安全性。

例如 0-RTT ECDH 密钥协商加密依赖了静态 static_svr_pri_key,不符合 PFS,我们可以使用 0-RTT ECDH-ECDHE 密钥协商,即进行 0-RTT ECDH 协商的过程中也进行 ECDHE 协商。0-RTT PSK 密钥协商的静态 ticket_key 同理也可以加入 ECDHE 协商。

verify_key 如何下发给客户端?

为避免证书链验证带来的时间消耗及传输带来的带宽消耗,直接将 verify_Key 内置客户端即可。

如何避免签名密钥 sign_key 泄露带来的影响?

因为 mmtls 内置了 verify_key 在客户端,必要时及时通过强制升级客户端的方式来撤销公钥并更新。

为什么要在上述密钥协商过程中都要引入 client_random、server_random、svr_pub_key 一起做签名?

因为 svr_pri_Key 可能会泄露,所有单独使用 svr_pub_key 时会有隐患,因为需要引入 client_random、server_random 来保证得到的签名值唯一对应一次握手。

Record 协议

1、认证加密

  • 1)、使用对称密钥进行安全通信。
  • 2)、加密 + 消息认证码:Encrypt-then-MAC
  • 3)、TLS 1.3 只使用 AEAD(Authenticated-Encryption With Addtional data)类算法:Encrypt 与 MAC 都集成在一个算法内部,让有经验的密码专家在算法内部解决安全问题。
  • 4)、mmtls 使用 AES-GCM 这种 AEAD 类算法。

2、密钥扩展

双方使用相同的对称密钥进行加密通信容易被某些对称密钥算法破解,因此,需要对原始对称密钥做扩展变换得到相应的对称加密参数。

密钥变长需要使用密钥延时函数(KDF,Key Derivation Function),而 TLS 1.3 与 mmtls 都使用了 HKDF 做密钥扩展。

3、防重放

为解决防重放,我们可以为连接上的每一个业务包都添加一个递增的序列号,只要 Server 检查到新收到的数据包的序列号小于等于之前收到的数据包的序列号,就判断为重放包,mmtls 将序列号作为构造 AES-GCM 算参数 nonce 的一部分,这样就不需要对序列号单独认证。

在 0-RTT 握手下,第一个业务数据包和握手数据包无法使用上述方案,此时需要客户端在业务框架层去协调支持防重放。

小结

mmtls 的 工作过程 如下所示:

  • 1)、使用 ECDH 做密钥协商。
  • 2)、使用 ECDSA 进行签名认证。
  • 3)、使用 AES-GCM 对称加密算法对业务数据进行加密。
  • 4)、使用 HKDF 进行密钥扩展。
  • 5)、使用的摘要算法为 SHA256。

其优势具有如下4点:

  • 1)、轻量级:去除客户端认证,内置签名公钥,减少验证时网络交换次数。
  • 2)、安全性:TLS 1.3 推荐安全性最高的基础密码组件,0-RTT 防重放由服务端、客户端框架层协同处理。
  • 3)、高性能:使用了 0-RTT 握手,优化了 TLS 1.3 中的握手方式和密钥扩展方式。
  • 4)、高可用:服务器添加了过载保护,确保其能在容灾模式下提供安全级别稍低的有损服务。

3)、复用 Session Ticket 会话,节省一个 RTT 耗时。

最后,我们可以在统一接入层对传输数据二次加密,需要注意二次加密会增加客户端与服务器的处理耗时。

如果手机设置了代理,TLS 加密的数据可以被解开并被利用,如何处理?

可以在 客户端锁定根证书,可以同时兼容老版本与保证证书替换的灵活性。

8、网络容灾机制

  • 1)、备用服务器分流。
  • 2)、多次失败后一定时间内不进行请求,避免雪崩效应。

9、资本手段优化

  • 1)、CDN 加速,更新后需要记住清理缓存
  • 2)、提高带宽
  • 3)、动静资源分离
  • 4)、部署跨国的专线、加速点
  • 5)、多 IDC 就进接入
  • 6)、P2P 技术

六、网络库设计

1、统一的网络中台

在一线互联网公司,都会有统一的网络中台:

  • 负责提供前后台一整套的网络解决方案。
  • 网关用于解决中间网络的通讯,为上层服务提供高质量的双向通讯能力。

2、如何设计一个优秀的统一网络库?

  • 1)、统一 API:统一的策略管理、流解析(兼容JSON、XML、Protocol Buffers)等
  • 2)、全局网络控制:统一的网络调度、流量监控、容灾管理等
  • 3)、高性能:速度、CPU、内存、I/O、失败率、崩溃率、协议兼容性等

3、统一网络库的核心模块有哪些?

  • 1)、DNS 管理
  • 2)、连接管理
  • 3)、协议处理
  • 4)、并发模型
  • 5)、IO 模型
  • 6)、预连接
  • 7)、错误兼容处理
  • 8)、数据解析
  • 9)、网络质量监控
  • 10)流量监控
  • 11)、代理 WebView 网络请求

4、高质量网络库

1)、Chromium 网络库

  • Google 出品,我们可以基于 Chromium 网络库二次开发自己的网络库, 以便享受 Google 后续网络优化的成果,例如 TLS 1.3、QUIC 支持等等。
  • 跨平台。
  • 需要补足 Mars 的 弱网/连接优化 功能。
  • 自定义协议:改造 TLS,将 RSA 更换为 ECDHE,以提升加解密速度。

2)、微信 Mars

一个跨平台的 Socket 层解决方案,不支持完整的 HTTP 协议。

Mars 的两个核心模块如下:

  • SDT:网络诊断模块
  • STN:信令传输模块,适合小数据传输。

其中 STN 模块的组成图如下所示:

包包超时

  • 每次读取或发送的间隔。
  • 获取 sock snd buf 内未发送的数据。
  • Android:ioctl 读取 SIOCOUTQ。
  • iOS:getsockopt 读取 SO_NWRITE。

动态超时

根据网络情况,调整其它超时的系数或绝对值。

Mars 是如何进行 连接优化 的?

复合连接

每间隔几秒启动一个新的连接,只要有连接建立成功,则关闭其它连接。=> 有效提升连接成功率。

自动重连优化

  • 1)、减少无效等待时间,增加重试次数。
  • 2)、但 TCP 层的重传间隔过大时,此时断连重连,能够让 TCP 层保持积极的重连间隔,以提高成功率。
  • 3)、当链路存在较大波动或严重拥塞时,通过更换连接以获得更好的性能。

网络切换

通过感知网络的状态切换到更好的网络环境下。

Mars 是如何进行 弱网优化 的?

常规方案

1)、快速重传
  • 减小重传成本(SACK、FEC)
  • 尽早发现重传(DUP ACK、FACK、RTO、NACK)
2)、HARQ(Hybrid Automatic Repeat reQuest)
  • 3 GPP 标准方案。
  • 增加并发度。
  • 尽量准确避免拥堵(丢包和拥堵的区别)。

进阶方案

TCP 丢包的恢复方式 TLP
  • 1、PTO 触发尾包重传。
  • 2、尾包的 ACK 带上 SACK 信息。
  • 3、SACK 触发 FACK 快速重传和恢复。
  • 4、避免了 RTO 导致的慢启动和延迟。
发图-有损下载

在弱网下尽量保证下载完整的图片轮廓显示,提高用户体验。

发图-有损上传数据
  • 在弱网下尽量保证上传完整的图片轮廓显示,提高用户体验。
  • 能够降低客户端上传失败率 10% 以上。

有损上传数据的流程,与有损下载流程同理:

  • 1)、发送渐进式图片(例如 JPG 等)。
  • 2)、服务器接收数据且回复数据确认包。
  • 3)、当数据足够时(50%),回复发送成功确认包。
  • 4)、发送方继续补充数据
    • 网络正常,数据完整。
    • 网络异常,认为已发送成功。
  • 5)、服务器通知发送者。
发图-低成本重传

将分包转成流式传输。

  • 1)、分包
    • 降低包大小
    • 增加并发
    • 包头损耗
  • 2)、流式 确认粒度策略灵活 单线程

七、其它优化方案

1、异地多活

一个多机房的整体方案,在多个地区同时存在对等的多个机房,以用户维度划分,多机房共同承担全量用户的流量。

在单个机房发送故障时,故障机房的流量可以快速地被迁引到可用机房,减少故障的恢复时间。

2、抗抖动优化

应用一种有策略的重试机制,将网络请求以是否发送到 socket 缓冲区作为分割,将网络请求生命周期划分为”请求开始到发送到 socket 缓冲区“和”已经发送到 socket 缓冲区到请求结束“两个阶段。

这样当用户进电梯因为网络抖动的原因网络链接断了,但是数据其实已经请求到了 socket 缓冲区,使用这种有策略的重试机制,我们就可以提升客户端的网络抗抖动能力。

3、SYNC 机制

同步差量数据,达到节省流量,提高通信效率与请求成功率。

客户端用户不在线时,SYNC 服务端将差量数据保持在数据库中。当客户端下次连接到服务器时,再同步差量数据给用户。

4、高并发流量处理:服务端接入层多级限流

核心思想是保障核心业务在体验可接受范围内做降级非核心功能和业务。从入口到业务接口总共分为四个层级,如下所示:

  • 1)、LVS(几十亿级):多 VIP 多集群。
  • 2)、接入网关(亿级):TCP 限流、核心 RPC 限流。
  • 3)、API 网关(千万级):分级限流算法(对不同请求量的接口使用不同的策略)
    • 高 QPS 限流:简单基数算法,超过这个值直接拒绝。
    • 中 QPS 限流:令牌桶算法,接受一定的流量并发。
    • 低 QPS 限流:分布式限流,保障限流的准确。
  • 4)、业务接口(百万级)
    • 返回定制响应、自定义脚本。
    • 客户端静默、Alert、Toast。

5、JobScheduler

结合 JobScheduler 来根据实际情况做网络请求. 比方说 Splash 闪屏广告图片, 我们可以在连接到 Wifi 时下载缓存到本地; 新闻类的 App 可以在充电, Wifi 状态下做离线缓存。

6、网络请求优先级排序

app应该对网络请求划分优先级尽可能快地展示最有用的信息给用户。(高优先级的服务优先使用长连接)

立刻呈现给用户一些实质的信息是一个比较好的用户体验,相对于让用户等待那些不那么必要的信息来说。这可以减少用户不得不等待的时间,增加APP在慢速网络时的实用性。(低优先级使用短连接)

7、建立长连通道

实现原理

将众多请求放入等待发送队列中,待长连通道建立完毕后再将等待队列中的请求放在长连通道上依次送出。

关键细节

HTTP 的请求头键值对中的的键是允许相同和重复的。例如 Set-Cookie/Cookie 字段可以包含多组相同的键名称数据。在长连通信中,如果对 header 中的键值对用不加处理的字典方式保存和传输,就会造成数据的丢失。

8、减少域名和避免重定向。

9、没有请求的请求,才是最快的请求。

七、网络体系化方案建设

1、线下测试

1)、正确认识

尽可能将问题在上线前暴露出来。

2)、侧重点

  • 1)、请求有误、多余
  • 2)、网络切换
  • 3)、弱网
  • 4)、无网

2、线上监控

1)、服务端监控

宏观监控维度

1)、请求耗时

区分地域、时间段、版本、机型。

2)、失败率

业务失败与请求失败。

3)、Top 失败接口、异常接口

以便进行针对性地优化。

微观监控维度

1)、吞吐量(requests per second)

RPS/TPS/QPS,每秒的请求次数,服务器最基本的性能指标,RPS 越高就说明服务器的性能越好。

2)、并发数(concurrency)

反映服务器的负载能力,即服务器能够同时支持的客户端数量,越大越好。

3)、响应时间(time per request)

反映服务器的处理能力,即快慢程度,响应时间越短越好。

4)、操作系统资源

CPU、内存、硬盘和网卡等系统资源。可以利用 top、vmstat 等工具检测相关性能。

优化方针

  • 1)、合理利用系统资源,提高服务器的吞吐量和并发数,降低响应时间。
  • 2)、选用高性能的 Web 服务器,开启长连接,提升 TCP 的传输效率。

2)、客户端监控

要实现客户端监控,首先我们应该要统一网络库,而客户端需要监控的指标主要有如下三类:

  • 1)、时延:一般我们比较关心每次请求的 DNS 时间、建连时间、首包时间、总时间等,会有类似 1 秒快开率、2 秒快开率这些指标。
  • 2)、维度:网络类型、国家、省份、城市、运营商、系统、客户端版本、机型、请求域名等,这些维度主要用于分析问题。
  • 3)、错误:DNS 失败、连接失败、超时、返回错误码等,会有 DNS 失败率、连接失败率、网络访问的失败率这些指标。

为了运算简单我们可以抛弃 UV,只计算每一分钟部分维度的 PV。

1、Aspect 插桩 — ArgusAPM

关于 ArgusAPM 的网络监控切面源码分析可以参考我之前写的 深入探索编译插桩技术(二、AspectJ) - 使用 AspectJ 打造自己的性能监控框架

缺点

监控不全面,因为 App 可能不使用系统/OkHttp 网络库,或是直接使用 Native 网络请求。

2、Native Hook

需要 Hook 的方法有三类:

  • 1)、连接相关:connect
  • 2)、发送数据相关:send 和 sendto。
  • 3)、接收数据相关:recv 和 recvfrom。

不同版本 Socket 的实现逻辑会有差异,为了兼容性考虑,我们直接 PLT Hook 内存所有的 so,但是需要排除掉 Socket 函数本身所在的 libc.so。其 PLT 的 Hook 代码如下所示:

hook_plt_method_all_lib("libc.so", "connect", (hook_func) &create_hook);
hook_plt_method_all_lib("libc.so, "send", (hook_func) &send_hook);
hook_plt_method_all_lib("libc.so", "recvfrom", (hook_func) &recvfrom_hook);
复制代码

下面,我们使用 PLT Hook 来获取网络请求信息。

项目地址

其成功 log 如下所示:

2020-05-21 15:10:37.328 27507-27507/com.dodola.socket E/HOOOOOOOOK: JNI_OnLoad
2020-05-21 15:10:37.328 27507-27507/com.dodola.socket E/HOOOOOOOOK: enableSocketHook
2020-05-21 15:10:37.415 27507-27507/com.dodola.socket E/HOOOOOOOOK: hook_plt_method
2020-05-21 15:10:58.484 27507-27677/com.dodola.socket E/HOOOOOOOOK: socket_connect_hook sa_family: 10
2020-05-21 15:10:58.495 27507-27677/com.dodola.socket E/HOOOOOOOOK: stack:com.dodola.socket.SocketHook.getStack(SocketHook.java:13)
libcore.io.Linux.connect(Native Method)
libcore.io.BlockGuardOs.connect(BlockGuardOs.java:126)
libcore.io.IoBridge.connectErrno(IoBridge.java:152)
libcore.io.IoBridge.connect(IoBridge.java:130)
java.net.PlainSocketImpl.socketConnect(PlainSocketImpl.java:129)
java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:356)
java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:200)
java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:182)
java.net.SocksSocketImpl.connect(SocksSocketImpl.java:357)
java.net.Socket.connect(Socket.java:616)
com.android.okhttp.internal.Platform.connectSocket(Platform.java:145)
com.android.okhttp.internal.io.RealConnection.connectSocket(RealConnection.java:141)
com.android.okhttp.internal.io.RealConnection.connect(RealConnection.java:112)
com.android.okhttp.internal.http.StreamAllocation.findConnection(StreamAllocation.java:184)
com.android.okhttp.internal.http.Strea
2020-05-21 15:10:58.495 27507-27677/com.dodola.socket E/HOOOOOOOOK: AF_INET6 ipv6 IP===>14.215.177.39:443
2020-05-21 15:10:58.495 27507-27677/com.dodola.socket E/HOOOOOOOOK: socket_connect_hook sa_family: 1
2020-05-21 15:10:58.495 27507-27677/com.dodola.socket E/HOOOOOOOOK: Ignore local socket connect
2020-05-21 15:10:58.523 27507-27677/com.dodola.socket E/HOOOOOOOOK: socket_connect_hook sa_family: 1
2020-05-21 15:10:58.523 27507-27677/com.dodola.socket E/HOOOOOOOOK: Ignore local socket connect
2020-05-21 15:10:58.806 27507-27677/com.dodola.socket E/HOOOOOOOOK: respond:<!DOCTYPE html>
<!--STATUS OK--><html> <head><meta http-equiv=content-type content=text/html;charset=utf-8><meta http-equiv=X-UA-Compatible content=IE=Edge><meta content=always name=referrer><link rel=stylesheet type=text/css href=https://ss1.bdstatic.com/5eN1bjq8AAUYm2zgoY3K/r/www/cache/bdorz/baidu.min.css><title>百度一下,你就知道</title></head> <body link=#0000cc> <div id=wrapper> <div id=head> <div class=head_wrapper> <div class=s_form> <div class=s_form_wrapper> <div id=lg> <img hidefocus=true src=//www.baidu.com/img/bd_logo1.png width=270 height=129> </div> <form id=form name=f action=//www.baidu.com/s class=fm> <input type=hidden name=bdorz_come value=1> <input type=hidden name=ie value=utf-8> <input type=hidden name=f value=8> <input type=hidden name=rsv_bp value=1> <input type=hidden name=rsv_idx value=1> <input type=hidden name=tn value=baidu><span class="bg s_ipt_wr"><input id=kw name=wd class=s_ipt value maxlength=255 autocomplete=off autofocus=autofocus></span><span class="bg s_btn_wr"><input type=submit id=su value=百度一下 class="bg s_btn" autofocus></span> </form> </div> </div> <div id=u1> <a href=http://news.baidu.com name=tj_trnews class=mnav>新闻</a> <a href=https://www.hao123.com name=tj_trhao123 class=mnav>hao123</a> <a href=http://map.baidu.com name=tj_trmap class=mnav>地图</a> <a href=http://v.baidu.com name=tj_trvideo class=mnav>视频</a> <a href=http://tieba.baidu.com name=tj_trtieba class=mnav>贴吧</a> <noscript> <a href=http://www.baidu.com/bdorz/login.gif?login&tpl=mn&u=http%3A%2F%2Fwww.baidu.com%2f%3fbdorz_come%3d1 name=tj_login class=lb>登录</a> </noscript> <script>document.write('<a href="http://www.baidu.com/bdorz/login.gif?login&tpl=mn&u='+ encodeURIComponent(window.location.href+ (window.location.search === "" ? "?" : "&")+ "bdorz_come=1")+ '" name="tj_login" class="lb">登录</a>');
</script> <a href=//www.baidu.com/more/ name=tj_briicon class=bri style="display: block;">更多产品</a> </div> </div> </div> <div id=ftCon> <div id=ftConw> <p id=lh> <a href=http://home.baidu.com>关于百度</a> <a href=http://ir.baidu.com>About Baidu</a> </p> <p id=cp>©2017 Baidu <a href=http://www.baidu.com/duty/>使用百度前必读</a>  <a href=http://jianyi.baidu.com/ class=cp-feedback>意见反馈</a> 京ICP证030173号  <img src=//www.baidu.com/img/gs.gif> </p> </div> </div> </div> </body> </html>
复制代码

此外,我们也可以使用爱奇艺提供的 android_plt_hook 来实现 PLT Hook。

缺点

接管了系统的 Local Socket,需要在代码中增加过滤条件。

3)、接入层监控

为什么要做接入层监控?

  • 1)、服务端更容易做到秒级的实时上报。
  • 2)、仅靠客户端的监控数据并不完全可靠。

监控维度

服务的入口和出口流量、服务端的处理时延、错误率等。

4)、监控报警

  • 1)、秒级或者分钟级别的实时监控只有访问量(PV)、错误率等几个维度:最快速度发现问题。
  • 2)、小时或者天级别的监控可以监控全部的维度:更好地定位出问题的区域。

监控的同时如何实现准确的自动化报警呢?

  • 1)、基于规则,例如失败率与历史数据相比暴涨、流量暴跌等。
  • 2)、基于时间序列算法或者神经网络的智能化报警,使用者不需要录入任何规则,只需有足够长的历史数据,就可以实现自动报警。

通常是两种结合使用。

3、异常监控体系搭建

1)、服务器防刷

超限拒绝访问。

2)、客户端

  • 1)、大文件预警
  • 2)、异常兜底策略:例如客户端超过5次连接失败,则设置更长的重试时间。

3)、单点问题追查

如果用户反馈 App 消耗的流量过多,或后台消耗流量较多,我们都可以具体地分析网络请求日志、以及下发命令查看具体时间段的流量、客户端线上监控 + 体系化方案建设 来实现单点问题的追查。

八、网络优化常见问题

1、在网络方面你们做了哪些监控,建立了哪些指标?

注意:体现演进的过程。

网络优化及监控我们刚开始并没有去做,因此我们在 APP 的初期并没有注意到网络的问题,并且我们通常是在 WIFI 场景下进行开发,所以并没有注意到网络方面的问题。

当 APP 增大后,用户增多,逐渐由用户反馈 界面打不开或界面显示慢,也有用户反馈我们 APP 消耗的流量比较多。在我们接受到这些反馈的时候,我们没有数据支撑,无法判断用户反馈是不是正确的。同时,我们也不知道线上用户真实的体验是怎样的。所以,我们就 建立了线上的网络监控,主要分为 质量监控与流量监控

1)、质量监控

首先,最重要的是接口的请求成功率与每步的耗时,比如 DNS 的解析时间、建立连接的时间、接口失败的原因,然后在合适的时间点上报给服务器。

2)、流量监控

首先,我们获取到了精准的流量消耗情况,并且在 APM 后台,可以下发指令获取用户在具体时间段的流量消耗情况。 => 引出亮点 => 前后台流量获取方案。 关于指标 => 网络监控。

2、怎么有效地降低用户的流量消耗?

注意:结合实际案例

1)、数据:缓存、增量更新(这一步减少了非常多的流量消耗)

首先,我们处理了项目当中展示数据相关的接口,同时,对时效性没那么强的接口做了数据的缓存,也就是一段时间内的重复请求直接走缓存,而不走网络请求,从而避免流量浪费。对于一些数据的更新,例如省市区域、配置信息、离线包等信息,我们 加上版本号的概念,以实现每次更新只传递变化的数据,即实现了增量更新 => 亮点:离线包增量更新实现原理与关键细节。

2)、上传:压缩

然后,我们在上传流量这方面也做了处理,比如针对 POST 请求,我们对 Body 做了 GZip 压缩,而对于图片的发送,必须要经过压缩,它能够在保证清晰度的前提下极大地减少其体积。

3)、图片:缩略图、webp

对于图片展示,我们采用了不同场景展示不同图片的策略,比如在列表展示界面,我们只展示了缩略图,而到用户显示大图的时候,我们才去展示原图。 => 引出 webp 的使用策略。

3、用户反馈消耗流量多这种问题怎么排查?

首先,部分用户遇到流量消耗多的情况是肯定会存在的,因为线上用户非常多,每个人遇到的情况肯定是不一样的,比如有些用户他的操作路径比较诡异,可能会引发一些异常情况,因此有些用户可能会消耗比较多的流量。

1)、精准获取流量的能力

我们在客户端可以精确q地获取到流量的消耗,这样就给我们排查用户的流量消耗提供了依据,我们就知道用户的流量消耗是不是很多。

2)、所有请求大小及次数的监控

此外,通过网络请求质量的监控,我们知道了用户所有网络请求的次数与大小,通过大小和次数排查,我们就能知道用户在使用过程中遇到了哪些 bug 或者是执行了一些异常的逻辑导致重复下载,处于不断重试的过程之中。

3)、主动预警的能力

在客户端,我们发现了类似的问题之后,我们还需要配备主动预警的能力,及时地通知开发同学进行排除验证,通过以上手段,我们对待用户的反馈就能更加高效的解决,因为我们有了用户所有的网络请求数据。

4、系统如何知道当前 WiFi 有问题?

如果一个 WiFi 发送过数据包,但是没有收到任何的 ACK 回包,这个时候就可以初步判断当前的 WiFi 是有问题的。

九、总结

网络优化可以说是移动端性能优化领域中水最深的领域之一,要想做好网络优化必须具备非常扎实的技术功底与全链路思维。总所周知,对于一个工程师的技术评级往往是以他最深入的那一两个领域为基准,而不是计算其技术栈的平均值。因此,建议大家能找准一两个点,例如 网络、内存、NDK、Flutter,对其进行深入挖掘,以打造自身的技术壁垒。

参考链接:


很感谢您阅读这篇文章,希望您能将它分享给您的朋友或技术群,这对我意义重大。

希望我们能成为朋友,在 Github掘金上一起分享知识。

文章分类
Android