解决方案:【实时数仓】DWD层需求分析及实现思路、idea环境搭建、实现DWD层处理用户行

优采云 发布时间: 2022-12-20 05:12

  解决方案:【实时数仓】DWD层需求分析及实现思路、idea环境搭建、实现DWD层处理用户行

  文章目录

  一、DWD层需求分析及实现思路 1、分层需求分析

  构建实时数仓的目的主要是增加数据计算的复用性。 每增加一个新的统计要求,不是从原来的数据计算,而是从半成品继续加工。

  这里从kafka的ods层读取用户行为日志和业务数据,简单处理后,作为dwd层回写给kafka。

  2 各层功能分层数据描述 生成计算工具 存储介质

  消耗臭氧层物质

  原创数据、日志和业务数据

  日志服务器,麦克斯韦

  卡夫卡

  DWD

  流式处理是以数据对象为单位进行的,例如订单、页面访问量等。

  弗林克

  卡夫卡

  DWM

  对于一些数据对象的进一步处理,比如独立访问,跳出行为。 还是详细的数据。

  弗林克

  卡夫卡

  暗淡

  维度数据

  弗林克

  数据库

  DWS

  将多个事实数据按照一个维度topic轻聚合,形成一个topic wide table。

  弗林克

  点击之家

  存托凭证

  根据可视化的需要,对Clickhouse中的数据进行过滤聚合。

  Clickhouse SQL

  可视化

  3 DWD层功能详解(一)用户行为日志数据

  根据不同类别的日志进行拆分。

  前端埋点的所有数据都放在Kafka中的ods_base_log主题中,比如启动日志、页面访问日志、曝光日志等,虽然日志都是一样的,但是分为不同的类型。 以后做数据统计的时候,不方便从这个题目中获取所有的数据。 因此,需要从ods_base_log主题中提取数据,根据日志类型将不同类型的数据放入不同的主题中,并进行分流操作,比如将启动日志放入启动主题中,将曝光登录曝光主题。 日志被放入日志主题中。

  (2) 业务数据

  根据业务数据类型(维度或事实)进行拆分。

  MySQL存储的业务数据中有很多表。 这些表分为两种,一种是事实表,一种是维表。 采集数据时,只要业务数据发生变化,就会通过maxwell采集到Kafka的ods_base_db_m主题中,不区分事实和维度。 如果是factual data,想放在kafka不同的单独topic中,比如order topic,order detail topic,payment topic等。对于维度数据,不适合放在kafka中存储。 Kafka不适合长期存储,默认存储7天。 海量数据的分析计算也不适合存储在MySQL中,因为在分析计算的过程中需要不断的进行查询操作,给业务数据库带来了很大的压力,而MySQL对于大量数据的查询性能较差。

  使用维度数据时,需要根据维度id查询具体数据。 KV型数据库更适合存储维度数据,根据K获取V效率更高。 KV数据库有Redis和Hbase,Redis长期存储压力比较大。 最终选择Hbase存储维度数据。

  4 DWD层数据准备实现思路2环境搭建1创建maven项目

  创建一个maven项目,gmall2022-realtime。

  2 修改配置文件 (1) 添加依赖

  

1.8

${java.version}

${java.version}

1.12.0

2.12

3.1.3

org.apache.flink

flink-java

${flink.version}

org.apache.flink

flink-streaming-java_${scala.version}

${flink.version}

org.apache.flink

flink-connector-kafka_${scala.version}

${flink.version}

org.apache.flink

flink-clients_${scala.version}

${flink.version}

org.apache.flink

flink-cep_${scala.version}

${flink.version}

org.apache.flink

flink-json

${flink.version}

com.alibaba

fastjson

1.2.68

<p>

org.apache.hadoop

hadoop-client

${hadoop.version}

org.slf4j

slf4j-api

1.7.25

org.slf4j

slf4j-log4j12

1.7.25

org.apache.logging.log4j

log4j-to-slf4j

2.14.0

org.apache.maven.plugins

maven-assembly-plugin

3.0.0

jar-with-dependencies

make-assembly

package

single

</p>

  (2) 添加配置文件

  在resources目录下创建log4j.properties配置文件

  log4j.rootLogger=error,stdout

log4j.appender.stdout=org.apache.log4j.ConsoleAppender

log4j.appender.stdout.target=System.out

log4j.appender.stdout.layout=org.apache.log4j.PatternLayout

log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - %m%n

  3 创建如下包结构

  目录角色

  应用程序

  生成各层数据的Flink任务

  豆子

  数据对象

  常见的

  公共常量

  效用

  工具

  3 准备用户行为日志——DWD层

  之前采集的日志数据已经保存在Kafka中。 作为日志数据的ODS层,Kafka从ODS层读取的日志数据分为三类,页面日志、启动日志、暴露日志。 这三类数据虽然都是用户行为数据,但是它们的数据结构完全不同,所以需要拆分处理。 将拆分后的日志写回Kafka的不同主题作为日志DWD层。

  页面日志输出到主流,启动日志输出到启动端输出流,曝光日志输出到曝光端输出流。

  1个主要任务

  整体流程如下:

  2 分区、分组和分流

  三者的关系和区别如下:

  3 代码实现(1)接收Kafka数据,转换为封装和操作Kafka的工具类,并提供获取kafka消费者的方法(阅读)

  /**

* 操作kafka工具类

*/

public class MyKafkaUtil {

private static final String KAFKA_SERVER = "hadoop101:9092,hadoop102:9092,hadoop103:9092";

// 获取kafka的消费者

public static FlinkKafkaConsumer getKafkaSource(String topic,String groupId){

Properties props = new Properties();

props.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,KAFKA_SERVER);

// 定义消费者组

props.setProperty(ConsumerConfig.GROUP_ID_CONFIG,groupId);

<p>

return new FlinkKafkaConsumer(topic,new SimpleStringSchema(),props);

}

}

</p>

  b Flink调用工具类主程序读取数据

  /**

* 对日志数据进行分流操作

* 启动、曝光、页面

* 启动日志放到启动侧输出流中

* 曝光日志放到曝光侧输出流中

* 页面日志放到主流中

* 将不同流的数据写回到kafka的dwd主题中

*/

public class BaseLogApp {

public static void main(String[] args) throws Exception{

// TODO 1 基本环境准备

// 流处理环境

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// 设置并行度

env.setParallelism(4);

// TODO 2 检查点相关设置

// 开启检查点

// 每5S中开启一次检查点,检查点模式为EXACTLY_ONCE

env.enableCheckpointing(5000L, CheckpointingMode.EXACTLY_ONCE);

// 设置检查点超时时间

env.getCheckpointConfig().setCheckpointTimeout(60000L);

// 设置重启策略

// 重启三次,每次间隔3s钟

env.setRestartStrategy(RestartStrategies.fixedDelayRestart(3,3000L));

// 设置job取消后,检查点是否保留

env.getCheckpointConfig().enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);

// 设置状态后端 -- 基于内存 or 文件系统 or RocksDB

// 内存:状态存在TaskManager内存中,检查点存在JobManager内存中

// 文件系统:状态存在TaskManager内存中,检查点存在指定的文件系统路径中

// RocksDB:看做和Redis类似的数据库,状态存在TaskManager内存中,检查点存在JobManager内存和本地磁盘上

// hadoop中nm的地址

env.setStateBackend(new FsStateBackend("hdfs://hadoop101:8020/ck/gmall"));

// 指定操作HDFS的用户

System.setProperty("HADOOP_USER_NAME","hzy");

// TODO 3 从kafka读取数据

// 声明消费的主题和消费者组

String topic = "ods_base_log";

String groupId = "base_log_app_group";

// 获取kafka消费者

FlinkKafkaConsumer kafkaSource = MyKafkaUtil.getKafkaSource(topic, groupId);

// 读取数据,封装为流

DataStreamSource kafkaDS = env.addSource(kafkaSource);

// TODO 4 对读取的数据进行结构的转换 jsonStr -> jsonObj

// // 匿名内部类实现

// SingleOutputStreamOperator jsonObjDS = kafkaDS.map(

// new MapFunction() {

// @Override

// public JSONObject map(String jsonStr) throws Exception {

// return JSON.parseObject(jsonStr);

// }

// }

// );

// // lambda表达式实现

// kafkaDS.map(

// jsonStr -> JSON.parse(jsonStr)

// );

// 方法的默认调用,注意导入的是alibaba JSON包

SingleOutputStreamOperator jsonObjDS = kafkaDS.map(JSON::parseObject);

jsonObjDS.print(">>>");

// TODO 5 修复新老访客状态

// TODO 6 按照日志类型对日志进行分流

// TODO 7 将不同流的数据写到kafka的dwd不同主题中

env.execute();

}

}

  测试

  # 启动zookeeper

# 启动kafka

# 启动采集服务

logger.sh start

# 启动nm以将检查点保存到hdfs上

start-dfs.sh

# 等待安全模式关闭,启动主程序,如果出现权限问题,可以将权限放开

hdfs dfs -chmod -R 777 /

# 或者增加以下代码到主程序中

System.setProperty("HADOOP_USER_NAME","hzy");

# 程序运行起来后,启动模拟生成日志数据jar包,在主程序中可以接收到数据

  解决方案:抖音品质建设 - iOS启动优化《实战篇》

  前言

  启动是App给用户的第一印象。 启动越慢,用户流失的概率就越高。 良好的启动速度是用户体验不可或缺的一部分。 启动优化涉及的知识点非常多,知识点范围很广。 文章难以面面俱到,分为原理和实战两部分。 本文为实战文章。

  原理:抖音品质建设——iOS启动优化《原理》

  如何做启动优化?

  在文章正式内容开始之前,大家可以想一想,如果自己来做启动优化,会怎么进行呢?

  这其实是一个比较大的问题。 遇到类似情况,我们可以把大问题分解成几个小问题:

  对应本文的三个模块:监控、工具和最佳实践。

  监控启动埋点

  既然要监控,那就需要在代码中能够获取到启动时间。 每个人都采用相同的起点作为起点:创建进程的时间。

  起始点对应于用户感知到的Launch Image消失的第一帧。 抖音采用的方案如下:

  苹果官方的统计方式是第一种CA::Transaction::commit,但是对应的实现是在系统框架内,抖音的方式非常接近这一点。

  分阶段

  排查线上问题,只有一个耗时的埋点入手显然是不够的。 可与单点埋点分阶段组合使用。 以下是抖音目前的监控方案:

  +load 和 initializer 的调用顺序与链接顺序有关。 链接顺序默认是按照CocoaPod Pod名称升序排列的,所以选择AAA开头的名称可以让某个+load和initializer先执行。

  无入侵监控

  公司的APM团队提供了一个非侵入式的启动监控方案,将启动过程拆分为几个与业务无关的相对粗粒度的阶段:进程创建、最早+load、didFinishLuanching启动和第一个屏幕的第一次绘制完成。

  前三个时间点的无创采集比较简单

  我们希望将首屏渲染完成时间与MetricKit对齐,即获取调用CA::Transaction::commit()方法的时间。

  通过Runloop源码分析和离线调试,我们发现CA::Transaction::commit()、CFRunLoopPerformBlock、kCFRunLoopBeforeTimers这三个时序的顺序如下:

  可以通过在didFinishLaunch中向Runloop注册block或者BeforeTimerObserver来获取上图中两个时间点的回调,代码如下:

  //注册block

CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];

CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){

    NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];

    NSLog(@"runloop block launch end:%f",stamp);

});

//注册kCFRunLoopBeforeTimers回调

CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];

CFRunLoopActivity activities = kCFRunLoopAllActivities;

CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {

    if (activity == kCFRunLoopBeforeTimers) {

        NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];

        NSLog(@"runloop beforetimers launch end:%f",stamp);

        CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);

    }

});

CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);

  经过实际测试,我们最终选择的非侵入式首屏渲染方案是:

  对于iOS13(含)及以上系统,在runloop中注册一个kCFRunLoopBeforeTimers的回调,获取App首屏渲染完成,更准确。 iOS13以下系统使用CFRunLoopPerformBlock方法注入block以更准确的定时获取App首屏渲染。监控周期

  App的生命周期可以分为研发、灰度、上线三个阶段。 不同阶段监测的目的和方法不同。

  发展阶段

  研发阶段监控的主要目的是防止劣化。 相应的,也会有离线自动监控。 通过实际启动性能测试,尽快发现问题并解决问题。 抖音离线自动监控抖音如下:

  由定时任务触发,先以发布方式打包,然后运行自动化测试。 测试完成后会上报测试结果,方便通过看板跟踪整体变化趋势。

  如果发现有降级,会先发出告警信息,然后通过二分查找找到对应的降级MR,然后自动运行火焰图和仪表辅助定位问题。

  那么如何保证检测结果稳定可靠呢?

  答案是控制变量:

  在实践中,我们发现iPhone 8的稳定性最好,其次是iPhone X,iPhone 6的稳定性很差。

  除了自动化测试之外,还可以在研发过程中增加一些访问权限,以防止启动降级。 这些访问包括

  不建议做细粒度的Code Review,除非你对相关业务有很好的了解,否则一般肉眼是看不出来有没有变质的。

  在线和灰度

  灰度和线上策略类似,主要看行情数据和配置报警。 市场监控和警报与公司的基础设施有很大关系。 如果没有对应的基础设施Xcode MetricKit本身也可以看到启动时间:打开Xcode -&gt; Window -&gt; Origanizer -&gt; Launch Time

  行情数据本身就是统计性的,会存在一些统计规律:

  基于这些背景,我们一般会控制变量:去掉地区、机型、版本,有时甚至会根据时间看启动时间的趋势。

  工具

  完成监控后,我们需要找到一些可以优化的点,需要借助工具。 主要包括仪器和自主开发两大类。

  时间分析器

  Time Profiler是一款在日常性能分析中被广泛使用的工具。 它通常会选择一个时间段,然后汇总分析调用栈的耗时。 但是Time Profiler其实只适合做粗粒度的分析。 你为什么这么说? 让我们来看看它是如何工作的:

  默认情况下,Time Profiler 会每隔 1ms 采样一次,只采集运行线程的调用堆栈,最后进行统计汇总。 比如下图中的5个样本中,没有对method3进行采样,所以最后聚合的stack中是看不到method3的。 所以在Time Profiler中看到的时间并不是代码的实际执行时间,而是stack在采样统计中出现的时间。

  Time Profiler 支持一些额外的配置。 如果计算时间和实际时间相差较大,可以尝试启用:

  系统跟踪

  既然Time Profiler支持粗粒度的分析,那么有没有什么细粒度的分析工具呢? 答案是系统跟踪。

  由于我们要细化分析,所以需要标记一小段时间,可以用Point of interest标记。 此外,系统跟踪对于分析虚拟内存和线程状态很有用:

  os_路标

  os_signpost 是 iOS 12 推出的用于在仪器中标记时间段的 API。 性能非常高,可以认为对启动没有影响。 结合开头提到的阶段性监控,我们可以在Instrument中将启动划分为多个阶段,结合其他模板具体问题具体分析:

  os_signpost结合swizzle可以产生意想不到的效果,比如hook所有的load方法分析对应的耗时,hook UIImage对应的方法统计启动路径上使用的图片的加载耗时。

  其他乐器模板

  除了这些,还有几个比较常用的模板:

  火焰图

  火焰图对于分析与时间相关的性能瓶颈非常有用,可以直接画出耗时的业务代码。 另外,可以自动生成火焰图,然后diff,可以用于自动归因。

  火焰图有两种常见的实现方式

  本质上就是在方法的开头和结尾加两个点就可以知道方法的耗时,然后转换成Chrome标准的json格式进行分析。 注意,即使使用mmap写文件,还是会出现一些错误,所以发现的问题不一定是问题,需要二次确认。

  最佳实践总体思路

  优化的整体思路其实就是四个步骤:

  

  删除启动项,最直接的方法。 如果你不能删除它,请尝试延迟它。 延迟包括首次访问和启动后找合适的时间预热。 如果不能延迟,可以试试并发。 如果不会多核多线程,可以试试。 让代码执行得更快

  本节将以Main函数为分界线,看Main函数前后的优化方案; 然后介绍如何优化Page In; 最后讲解一些非常规的优化方案,对架构的要求比较高。

  主要之前

  Main函数之前的启动过程如下:

  动态库

  减少动态库的数量可以减少在启动关闭时创建和加载动态库所花费的时间。 官方建议动态库数量小于6个。

  推荐的方式是将动态库转换为静态库,因为这样可以额外减少包的大小。 另一种方式是合并动态库,但是在实践中可操作性不是很高。 最后要提的是,不要链接那些你不使用的库(包括系统),因为它会减慢闭包的创建速度。

  离线代码

  离线代码可以减少 Rebase &amp; Bind &amp; Runtime 初始化的耗时。 那么如何找到不用的代码,然后下线呢? 分为静态扫描和在线统计。

  最简单的静态扫描是基于AppCode,但是项目大后AppCode的索引速度很慢。 另一种静态扫描是基于 Mach-O 的:

  如果你把两者区别一下就知道那些classes/sel没有用到,但是objc是支持运行时调用的,删除前需要确认两次。

  统计无用代码的另一种方法是使用在线数据统计。 主流的解决方案有以下三种:

  前两种是ROI较高的解决方案,大部分时候Class级别的渗透率就足够了。

  +加载迁移

  除了方法本身耗时,+load也会造成大量的Page In,+load的存在也会影响App的稳定性,因为无法捕捉到crash。

  比如很多DI容器需要给类绑定协议,所以需要在启动的时候提前注册(+load):

  + (void)load

{

    [DICenter bindClass:IMPClass toProtocol:@protocol(SomeProcotol)]

}

  本质上只要知道protocol和class的对应关系,使用clang属性,这个过程就可以迁移到编译期:

  typedef struct{

    const char * cls;

    const char * protocol;

}_di_pair;

#if DEBUG

#define DI_SERVICE(PROTOCOL_NAME,CLASS_NAME)\

__used static Class _DI_VALID_METHOD(void){\

    return [CLASS_NAME class];\

}\

__attribute((used, section(_DI_SEGMENT "," _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \

{\

_TO_STRING(CLASS_NAME),\

_TO_STRING(PROTOCOL_NAME),\

};\

#else

__attribute((used, section(_DI_SEGMENT "," _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \

{\

_TO_STRING(CLASS_NAME),\

_TO_STRING(PROTOCOL_NAME),\

};\

#endif

  原理很简单:宏提供接口,编译时将类名和协议名写入二进制的指定段,运行时读出关系就知道协议绑定在哪个类上。

  有同学会注意到有一个无用的方法_DI_VALID_METHOD,它只存在于debug模式下,目的是为了让编译器保证类型安全。

  静态初始化迁移

  静态初始化,和+load方法一样,也会造成大量的Page In,通常来自C++代码,比如网络或者特效库。 另外,一些静态初始化是通过头文件引入的,可以通过预处理来确认。

  几种典型的迁移思路:

  //Bad

namespace {

    static const std::string bucket[] = {"apples", "pears", "meerkats"};

}

const std::string GetBucketThing(int i) {

     return bucket[i];

}

//Good

std::string GetBucketThing(int i) {

  static const std::string bucket[] = {"apples", "pears", "meerkats"};

  return bucket[i];

}

  Main 之后的启动器

  启动需要框架来控制,抖音采用轻量级的中心化方案:

  启动任务的执行过程如下:

  为什么需要启动器?

  三方SDK

  一些第三方SDK启动时间比较长,比如Fabric,抖音下线后抖音的启动速度快了70ms左右。

  除了下线,很多SDK都可以延迟,比如分享、登录等。另外,在接入SDK之前,可以先评估一下对启动性能的影响。 如果影响较大,可以反馈给SDK提供商修改,尤其是付费SDK。 他们其实很愿意配合,做一些修改。

  高频法

  有些方法单独耗时不高,但在启动路径上会被多次调用,累计起来耗时不低。 比如读取Info.plist中的配置:

  + (NSString *)plistChannel

{

    return [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CHANNEL_NAME"];

}

  修改的方式很简单,加一层内存缓存即可。 这种问题在TimeProfiler中选择时间段的时候经常可以看到。

  锁

  锁之所以影响启动时间,是因为有时候子线程先持有锁,主线程需要等待子线程锁释放。 还要警惕系统会有很多隐藏的全局锁,比如dyld和Runtime。 例如:

  

  下图是UIImage imageNamed造成的主线程阻塞:

  从右边的栈可以看出,imageNamed触发dlopen,dlopen等待dyld的全局锁。 通过System Trace的Thread State Event,可以找到线程被阻塞的下一个事件。 此事件表明该线程可以再次运行,因为其他线程已经释放了锁:

  接下来通过分析此时后台线程在做什么,就会知道为什么会持有锁,以及如何优化。

  线程数

  线程的数量和优先级都会影响启动时间。 您可以通过设置 QoS 来配置优先级。 两个高质量的 QoS 是用户交互的/发起的。 启动时需要主线程等待的子线程任务要设置为高质量。

  优质线程的数量不应超过 CPU 内核的数量。 您可以通过System Trace 的System Load 来分析这种情况。

  /GCD

dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, -1);

dispatch_queue_t queue = dispatch_queue_create("com.custom.utility.queue", attr);

//NSOperationQueue

operationQueue.qualityOfService = NSQualityOfServiceUtility

  线程数也会影响启动时间,但在iOS中全局控制线程并不容易。 比如二/三方库的后台线程不好控制,但是业务线程可以通过启动任务来控制。

  线程多没关系,只要同时执行的并发不多,可以通过System Trace查看上下文切换耗时,确认线程数是否是启动瓶颈.

  图片

  难免会用到很多图片来启动。 有什么办法可以优化图片加载的耗时吗?

  使用 Asset 管理图像,而不是直接将它们放在 bundle 中。 资产将在编译期间进行优化,以加快加载速度。 另外在Asset中加载图片比Bundle更快,因为UIImage imageNamed需要遍历Bundle来寻找图片。 Asset中加载图片的耗时主要在第一张图片,因为需要建立索引,这部分耗时可以通过将启动的图片放到一个小的Asset中来减少。

  每次创建一个UIImage,都需要IO,在渲染第一帧的时候解码。 所以这个耗时的部分可以通过在子线程之前预加载(创建UIImage)来优化。

  如下图所示,图像只会在后期的“RootWindow Creation”和“First Frame Rendering”中使用,所以可以在启动前期启动预加载的子线程来启动任务。

  鱼钩

  fishhook是一个用来hook C函数的库,但是第一次调用这个库比较耗时,所以最好不要带上线。 Fishhook如下图遍历Mach-O的多个段,找到函数指针和函数符号名的映射关系。 副作用是需要大量的Page Ins。 对于大型应用,在 iPhone X 上启动冷启动需要时间 200ms+。

  如果一定要使用fishhook,请在子线程中调用,不要直接在_dyld_register_func_for_add_image中调用fishhook。 因为这个方法会持有一个dyld的全局互斥量,系统库经常在主线程启动时调用dlsym和dlopen,内部也需要这个锁,导致上述子线程阻塞主线程。

  第一帧渲染

  不同的APP有不同的业务形态,首帧渲染优化方式也大不相同。 几个常见的优化点:

  其他提示

  启动优化有一些需要注意的tips:

  不要删除tmp/com.apple.dyld目录,因为iOS 13+的启动闭包存放在该目录下。 如果删除了,下次启动时会重新创建。 创建闭包的过程非常缓慢。 接下来是IO优化。 一种常见的方式是使用mmap来使IO更快,也可以在启动的早期预加载数据。

  还有一些点在iPhone 6上耗时会明显增加:

  iPhone 6是一个分水岭,性能会跌落悬崖,你可以在iPhone 6上降低部分用户交互来换取核心体验(切记AB验证)。

  Page In耗时

  启动路径上会触发大量Page Ins。 有没有办法优化这部分耗时?

  部分重命名

  App Store 会对上传的 App 的 TEXT 部分进行加密,并在 Page In 发生时对其进行解密。 解密过程非常耗时。 由于TEXT部分会被加密,直接的思路就是将TEXT部分的内容移动到其他部分。 ld还有一个参数rename_section来支持重命名:

  抖音更名方案:

  "-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring",

"-Wl,-rename_section,__TEXT,__const,__RODATA,__const",

"-Wl,-rename_section,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab",

"-Wl,-rename_section,__TEXT,__objc_methname,__RODATA,__objc_methname",

"-Wl,-rename_section,__TEXT,__objc_classname,__RODATA,__objc_classname",

"-Wl,-rename_section,__TEXT,__objc_methtype,__RODATA,__objc_methtype"

  这种优化方式在iOS 13下是有效的,因为iOS 13优化了解密过程,Page In在的时候不需要解密,这也是iOS 13启动速度更快的原因之一。

  二元重排

  由于在启动路径上会触发大量的Page Ins,请问有什么办法可以优化吗?

  启动具有局部性的特点,即启动时只用到少量函数,而且这些函数分布比较分散,因此Page In读取的数据利用率不高。 如果我们可以将用于启动的函数安排成连续的二进制区间,那么我们就可以减少Page In的数量,从而优化启动时间:

  以下图为例。 启动时使用方法一和方法三。 为了执行相应的代码,需要两次 Page Ins。 如果我们把方法一和方法三放在一起,那么只需要一次Page In,从而提高了启动速度。

  链接器 ld 有一个参数 -order_file 支持根据符号排列二进制文件。 启动时使用的主流符号有两种获取方式:

  Facebook的LLVM function instrumentation是为order_file定制的,代码也是他们为LLVM开发的,已经合并到LLVM的主分支中。

  Facebook的方案更精细,生成的order_file是最优方案,但工作量巨大。 抖音的方案不需要源码编译,不需要修改现有的编译环境和流程,侵入性最小。 缺点是只能覆盖90%左右的符号。

  - 灰度是任何优化的好阶段,因为很多新的优化方案都存在不确定性,需要先在灰度上进行验证。

  非常规方案动态库懒加载

  一开始我们提到可以通过删除代码来减少代码量,那么有没有什么办法可以在不减少代码总量的情况下减少启动时加载的代码量呢?

  什么是懒加载动态库? 普通的动态库直接或间接地由主二进制文件链接,因此这些动态库将在启动时加载。 如果只是打包进App,不参与链接,那么启动时不会自动加载。 当运行时需要动态库中的内容时,会手动延迟加载。

  动态库的延迟加载在编译时和运行时都需要修改。 编译时的架构:

  像 A.framework 这样的动态库是延迟加载的,因为它们不参与主二进制文件的直接或间接链接。 动态库之间必然存在一些共同的依赖关系,将这些依赖关系打包成Shared.framework来解决公共依赖关系的问题。

  运行时由-[NSBundle load]加载,本质上是调用底层的dlopen。 那么什么时候触发手动加载动态库呢?

  动态库可以分为业务类和函数类两种。 业务是UI的入口,动态库加载的逻辑可以汇聚到路由内部,让外部实际上不知道动态库是懒加载的,可以更好的断层——宽容。 函数库(比如上图中的QR.framework)会有点不同,因为没有UI等入口,函数库需要自己维护Wrapper:

  动态库懒加载除了减少启动加载的代码外,还可以防止业务长时间添加代码导致的启动降级,因为业务在第一次访问时就完成了初始化。

  该解决方案还有其他优点。 比如动态库转换后本地编译时间会大大减少,对其他性能指标也有好处。 缺点是会牺牲一定的包体积,但是懒加载的动态库可以通过分段压缩等方式进行优化。 来平衡这部分损失。

  后台获取

  Background Fetch 可以定时在后台启动应用,对时间敏感的应用(比如新闻)在后台刷新数据,可以提高提要加载速度,提升用户体验。

  那么,为什么这种“后台保活”机制能够提高启动速度呢? 我们来看一个典型案例:

  系统在后台启动应用程序需要很长时间。 因为内存等原因,后台App被kill了。 此时,用户立即启动App。 那么这个启动就是热启动,因为缓存还在。 另一次系统在后台启动应用程序。 这个时候用户在App在后台的时候点击App,那么这个启动就是后台回到前台,因为App还活着

  通过这两个典型场景,我们可以看出为什么Background Fetch可以提高启动速度:

  启动后台有一些需要注意的地方,比如日常活动,广告,甚至AB组入口的逻辑都会受到影响,需要大量的适配。 经常需要launcher来支持,因为didFinishLaunch中执行的task在后台启动时需要延迟到第一次回到前台才正常启动。

  总结

  最后,提取了我们认为在任何优化中都很重要的几点:

  加入我们

  我们是负责抖音客户端基础能力研发和新技术探索的团队。 我们深耕工程/业务架构、研发工具、编译系统等,支持业务快速迭代,同时保证超*敏*感*词*团队的研发效率和工程质量。 在性能/稳定性等方面不断探索,力求为全球亿万用户提供最极致的基础体验。

  如果你热爱技术,欢迎加入抖音基础技术团队,让我们一起打造亿级国民APP。 目前我们在上海、北京、杭州、深圳都有招聘需求。 内推可以联系邮箱:,邮件标题:姓名-工作年限-抖音-基础技术抖音 /Android。

  欢迎关注“字节跳动技术团队”

  简历投递联系邮箱:“ ”

0 个评论

要回复文章请先登录注册


官方客服QQ群

微信人工客服

QQ人工客服


线