编程进阶网编程进阶网
  • 基础组成体系
  • 程序编程原理
  • 异常和IO系统
  • 六大设计原则
  • 设计模式导读
  • 创建型设计模式
  • 结构型设计模式
  • 行为型设计模式
  • 设计模式案例
  • 面向对象思想
  • 基础入门
  • 高级进阶
  • JVM虚拟机
  • 数据集合
  • Java面试题
  • C语言入门
  • C综合案例
  • C标准库
  • C语言专栏
  • C++入门
  • C++综合案例
  • C++专栏
  • HTML
  • CSS
  • JavaScript
  • 前端专栏
  • Swift
  • iOS入门
  • 基础入门
  • 开源库解读
  • 性能优化
  • Framework
  • 方案设计
  • 媒体音视频
  • 硬件开发
  • Groovy
  • 常用工具
  • 大厂面试题
  • 综合案例
  • 网络底层
  • Https
  • 网络请求
  • 故障排查
  • 专栏
  • 数组
  • 链表
  • 栈
  • 队列
  • 树
  • 递归
  • 哈希
  • 排序
  • 查找
  • 字符串
  • 其他
  • Bash脚本
  • Linux入门
  • 嵌入式开发
  • 代码规范
  • Markdown
  • 开发理论
  • 开发工具
  • Git管理
  • 百宝箱
  • 开源协议
  • 技术招聘
  • 测试经验
  • 职场提升
  • 技术模版
  • 关于我
  • 目标清单
  • 学习框架
  • 育儿经验
  • 我的专栏
  • 底层能力
  • 读书心得
  • 随笔笔记
  • 职场思考
  • 中华历史
  • 经济学故事
  • 基础组成体系
  • 程序编程原理
  • 异常和IO系统
  • 六大设计原则
  • 设计模式导读
  • 创建型设计模式
  • 结构型设计模式
  • 行为型设计模式
  • 设计模式案例
  • 面向对象思想
  • 基础入门
  • 高级进阶
  • JVM虚拟机
  • 数据集合
  • Java面试题
  • C语言入门
  • C综合案例
  • C标准库
  • C语言专栏
  • C++入门
  • C++综合案例
  • C++专栏
  • HTML
  • CSS
  • JavaScript
  • 前端专栏
  • Swift
  • iOS入门
  • 基础入门
  • 开源库解读
  • 性能优化
  • Framework
  • 方案设计
  • 媒体音视频
  • 硬件开发
  • Groovy
  • 常用工具
  • 大厂面试题
  • 综合案例
  • 网络底层
  • Https
  • 网络请求
  • 故障排查
  • 专栏
  • 数组
  • 链表
  • 栈
  • 队列
  • 树
  • 递归
  • 哈希
  • 排序
  • 查找
  • 字符串
  • 其他
  • Bash脚本
  • Linux入门
  • 嵌入式开发
  • 代码规范
  • Markdown
  • 开发理论
  • 开发工具
  • Git管理
  • 百宝箱
  • 开源协议
  • 技术招聘
  • 测试经验
  • 职场提升
  • 技术模版
  • 关于我
  • 目标清单
  • 学习框架
  • 育儿经验
  • 我的专栏
  • 底层能力
  • 读书心得
  • 随笔笔记
  • 职场思考
  • 中华历史
  • 经济学故事
  • 01.LeakCanary设计思想
  • 02.Glide图片加载设计
  • 03.Gson序列化方案设计
  • 04.ARouter路由实践设计
  • 05.EventBus事件设计
  • 07.OkHttp网络请求设计
  • 08.gRPC框架设计思想

01.LeakCanary设计思想

目录介绍

  • 01.整体概述介绍
    • 1.1 项目背景介绍
    • 1.2 遇到问题说明
    • 1.3 设计目标
    • 1.4 产生收益
  • 02.开发设计思路
    • 2.1 整体设计思路
    • 2.2 设计初始化思路
    • 2.3 如何设计组件监听
    • 2.4 设计无用对象监听
    • 2.5 设计内存泄漏监听
    • 2.6 设计Dump内存
    • 2.7 设计分析内存快照
    • 2.8 设计输出堆栈链报告
    • 2.9 KOOM设计思路
  • 03.LeakCanary原理
    • 3.1 要思考一些问题
    • 3.2 原理流程的概括
    • 3.3 初始化流程
    • 3.4 监听组件销毁
    • 3.5 监听无用对象
    • 3.6 监控内存泄漏
    • 3.7 Dump内存快照
    • 3.8 分析堆快照
    • 3.9 输出分析报告
  • 04.一些技术点思考
    • 4.5 引用链如何生成
    • 4.6 分析引擎的原理
    • 4.7 提高Dump分析效率
    • 4.8 相同问题分组
    • 4.9 如何标记怀疑对象
  • 05.方案基础设计
    • 5.1 整体架构图
    • 5.2 UML设计图
    • 5.3 关键流程图
    • 5.4 接口设计图
    • 5.5 模块间依赖关系
  • 06.其他设计说明
    • 6.1 性能设计优化
    • 6.2 稳定性设计
    • 6.3 灰度设计
    • 6.4 降级设计
    • 6.5 异常设计优化

01.整体概述介绍

1.1 项目背景介绍

1.2 基础概念

  • 什么是内存泄漏?
    • 当我们App无法释放不需要的对象引用时,即为内存泄漏。也可以理解为:生命周期长的持有了生命周期短的对象所导致。
    • 内存泄露(Memory Leaks)指不再使用的对象或数据没有被回收,随着内存泄漏的堆积,应用性能会逐渐变差,甚至发生 OOM 奔溃。
  • 应用中的内存泄漏可以分为 2 类:
    • Java 内存泄露: 不再使用的对象被生命周期更长的 GC Root 引用,无法被判定为垃圾对象而导致内存泄漏(LeakCanary 只能监控 Java 内存泄漏);
    • Native 内存泄露: Native 内存没有垃圾回收机制,未手动回收导致内存泄漏。

1.3 设计目标

  • LeakCanary 支持以下五种 Android 场景中的内存泄漏监测:
    • 1、已销毁的 Activity 对象(进入 DESTROYED 状态);
    • 2、已销毁的 Fragment 对象和 Fragment View 对象(进入 DESTROYED 状态);
    • 3、已清除的的 ViewModel 对象(进入 CLEARED 状态);
    • 4、已销毁的的 Service 对象(进入 DESTROYED 状态);
    • 5、已从 WindowManager 中移除的 RootView 对象;

1.4 产生收益

  • LeakCanary 的特点或优势
    • 在于提前预判出 Android 应用中最常见且影响较大的内存泄漏场景,并对此做针对性的监测手段。更加高效。

02.开发设计思路

2.1 整体设计思路

  • LeakCanary 的核心工作流程,将其概括为以下 5 个阶段:
    • image
      image
  • 1、注册无用对象监听:
    • 在 Android Framework 中注册监听器,感知五种 Android 内存泄漏场景中产生无用对象的时机(例如在 Activity#onDestroy() 后,产生一个无用 Activity 对象);
  • 2、监控内存泄漏:
    • 为无用对象关联弱引用对象,如果一段时间后引用对象没有按预期进入引用队列,则认为对象发生内存泄漏。
    • 由于分析堆快照是耗时工作,所以 LeakCanary 不会每次发现内存泄漏对象都进行分析工作,而是内存泄漏对象计数到达阈值才会触发分析工作。在计数未到达阈值的过程中,LeakCanary 会发送一条系统通知,你也可以点击该通知提前触发分析工作;
  • 3、Java Heap Dump:
    • 当泄漏对象计数达到阈值时,会触发 Java Heap Dump 并生成 .hprof 文件存储到文件系统中。Heap Dump 的过程中会锁堆,会使应用冻结一段时间;
  • 4、分析堆快照:
    • LeakCanary 会根据应用的依赖项,选择多进程、或 Thread 异步任务其中一种策略来执行分析。
    • 分析过程 LeakCanary 使用 Shark 分析 .hprof 文件,替换了 LeakCanary 1.0 使用的 haha ;
  • 5、输出分析报告:
    • 当分析工作完成后,LeakCanary 会在 Logcat 打印分析结果,也会发送一条系统通知消息。点击通知消息可以跳转到可视化分析报告页面,也可以点击 LeakCanary 生成的桌面快捷方式进入。

2.2 设计初始化思路

  • LeakCanary 的初始化工程可以概括为 2 项内容:
    • 1、初始化 LeakCanary 内部分析引擎;
    • 2、在 Android Framework 上注册多种 Android 泄漏场景的监控。

2.3 如何设计组件监听

  • Activity 监控:
    • 通过 Application#registerActivityLifecycleCallbacks(…) 接口监听 Activity#onDestroy 事件,将当前 Activity 对象交给 ObjectWatcher 监控;
  • Fragment 与 Fragment View 监控:
    • 首先是通过 Application#registerActivityLifecycleCallbacks(…) 接口监听 Activity#onCreate 事件,再通过 FragmentManager#registerFragmentLifecycleCallbacks(…) 接口监听 Fragment 的生命周期;
  • ViewModel 监控:
    • 由于 Android Framework 未提供设置 ViewModel#onClear() 全局监听的方法,所以 LeakCanary 是通过 Hook 的方式实现。
    • 即:在 Activity#onCreate 和 Fragment#onCreate 事件中实例化一个自定义ViewModel,在进入 ViewModel#onClear() 方法时,通过反射获取当前作用域中所有的 ViewModel 对象交给 ObjectWatcher 监控。
  • Service 监控:
    • 由于 Android Framework 未提供设置 Service#onDestroy() 全局监听的方法,所以 LeakCanary 是通过 Hook 的方式实现的。
  • RootView 监控:
    • 由于 Android Framework 未提供设置全局监听 RootView 从 WindowManager 中移除的方法,所以 LeakCanary 是通过 Hook 的方式实现的,这一块是通过 squareup 另一个开源库 curtains 实现的。

2.4 设计无用对象监听

  • 如何标记一个对象,判断是否泄漏。
    • 为弱引用指定一个引用队列,当弱引用指向的对象被回收时,此弱引用就会被添加到这个队列中,我们可以通过判断这个队列中有没有这个弱引用,来判断该弱引用指向的对象是否被回收了。
  • 通过弱引用判断内存是否泄漏案例
    // 创建一个引用队列
    ReferenceQueue<Object> queue = new ReferenceQueue<>();
    
    private void test() {
        // 创建一个对象
        Object obj = new Object();
        // 创建一个弱引用,并指向这个对象,并且将引用队列传递给弱引用
        WeakReference<Object> reference = new WeakReference(obj, queue);
          // 打印出这个弱引用,为了跟gc之后queue里面的对比证明是同一个
          System.out.println("这个弱引用是:" + reference);
        // gc一次看看(毛用都没)
        System.gc();
        // 打印队列(应该是空)
        printlnQueue("before");
    
        // 先设置obj为null,obj可以被回收了
        obj = null;
        // 再进行gc,此时obj应该被回收了,那么queue里面应该有这个弱引用了
        System.gc();
        // 再打印队列
        printlnQueue("after");
    }
    
    private void printlnQueue(String tag) {
        System.out.print(tag);
        Object obj;
        // 循环打印引用队列
        while ((obj = queue.poll()) != null) {
            System.out.println(": " + obj);
        }
        System.out.println();
    }
  • 打印结果如下所示:
    这个弱引用是:java.lang.ref.WeakReference@6e0be858
    before
    after: java.lang.ref.WeakReference@6e0be858
  • 通过上述代码,我们看到
    • 当obj不为null时,进行gc,发现queue里面什么都没有;然后将obj置为null之后,再次进行gc,发现queue里面有这个弱引用了,这就说明obj已经被回收了
  • 利用这个特性,我们就可以检测Activity 的内存泄漏
    • Activity在onDestroy()之后被销毁,那么我们如果利用弱引用来指向Activity,并为它指定一个引用队列,然后在onDestroy()之后,去查看引用队列里是否有该Activity对应的弱引用,就能确定该Activity是否被回收了。

2.5 设计内存泄漏监听

  • 1、在 Android Framework 中注册无用对象监听:
    • 通过全局监听器或者 Hook 的方式,在 Android Framework 上监听 Activity 和 Service 等对象进入无用状态的时机(例如在 Activity#onDestroy() 后,产生一个无用 Activity 对象);
  • 2、利用引用对象可感知对象垃圾回收的机制判定内存泄漏:
    • 为无用对象包装弱引用,并在一段时间后(默认为五秒)观察弱引用是否如期进入关联的引用队列,是则说明未发生泄漏,否则说明发生泄漏(无用对象被强引用持有,导致无法回收,即泄漏)。
  • LeakCanary 发现泄漏对象后就会触发分析吗?
    • Watcher 判定被监控对象发生泄漏后,会做一些判断逻辑。LeakCanary 不会每次发现内存泄漏对象都进行分析工作,而会进行两个拦截:
    • 拦截 1:泄漏对象计数未达到阈值,或者进入后台时间未达到阈值;拦截 2:计算距离上一次 HeapDump 未超过 60s。

2.6 设计Dump内存

  • LeakCanary 已经成功生成 .hprof 堆快照文件,并且发送了一个 LeakCanary 内部事件 HeapDump。那么这个事件在哪里被消费的呢?
    • 会开启一个前台服务,在后台进行Dump操作【注意它是个耗时行为】
  • Dump内存的设计思路是怎样的?
    • 1、分析堆快照;2、发送分析进度事件;3、发送分析完成事件。
  • Dump内存如何设计触发堆快照,生成hprof文件
    • 如果内存真的泄露,则发起 heap dump,分析 dump 文件,找到引用链。但是是每次泄漏都会触发该操作吗?
    • 第一种策略:如果两次Dump之间时间少于60s,也会直接返回,避免频繁Dump。
    • 第二种策略:默认情况下,如果泄漏对象计数未达到阈值,不会进行Dump,节省资源

2.7 设计分析内存快照

  • 分析内存快照的核心就是Dump内存文件

2.9 KOOM设计思路

  • Koom加快Dump速度的设计思路
    • LeakCanary 默认的 Java Heap Dump 使用的是 Debug.dumpHprofData() ,在 Dump 的过程中会有较长时间的应用冻结时间。
    • 快手技术团队在开源框架 Koom 中提出了优化方案:利用 Copy-on-Write 思想,fork 子进程再进行 Heap Dump 操作。

03.LeakCanary原理

3.1 思考一些问题

  • 首先思考几个问题
    • LeakCanary:LeakCanary 的核心工作流程,它的设计思想是什么?初始化做了那些工作?如何设计组件的监听(怎么监听ViewModel,Service销毁事件)?
    • LeakCanary:如何判断内存的泄漏?内存泄漏的判定条件是什么 ?检测内存泄漏的机制原理是什么?发现泄漏对象后就会触发分析吗?
    • LeakCanary:检测出内存泄漏后,它又是如何生成泄漏信息的? 内存泄漏的输出轨迹是怎么得到的?为什么不能用到线上环境?
  • 原理简单介绍
    • 通过监听Activity的onDestroy,手动调用GC,然后通过ReferenceQueue+WeakReference,来判断Activity对象是否被回收,然后结合dump Heap的hprof文件,通过Haha开源库分析泄露的位置。

3.2 原理流程概括

  • 如何触发检测
    • 检测保留的实例 LeakCanary 是基于 LeakSentry 开发的,LeakSentry 会 hook Android 声明周期,并且会自动检测当 Activity 或 Fragment 被销毁时,它们的实例是否被回收了。
    • 销毁的实例会传给 RefWatcher,RefWatcher 会持有它们的弱引用。 你也可以观察所有不再需要的实例,比如一个不再使用的 View,不再使用的 Presenter 等。
    • 如果等待了 5 秒,并且 GC 触发了之后,弱引用还没有被清理,那么 RefWatcher 观察的实例就可能处于内存泄漏状态了。
  • 判断是否存在内存泄漏
    • 首先尝试着从ReferenceQueue队列中获取待分析对象(软引用和弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用或弱引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用或弱引用加入到与之关联的引用队列中),如果不为空,那么说明正在被系统回收。
    • 如果直接就返回DONE,说明已经被系统回收了。如果没有被系统回收,可能存在内存泄漏,手动触发系统GC,然后再尝试移除待分析对象。如果还存在,说明存在内存泄漏。
  • 分析内存泄漏
    • 确定有内存泄漏后,调用heapDumper.dumpHeap()生成.hprof文件目录。HAHA 是一个由 square 开源的Android 堆分析库,分析hprof文件生成Snapshot对象。Snapshot用以查询对象的最短引用链。找到最短引用链后,定位问题,排查代码将会事半功倍。
    • image
      image
    • 泄漏分组 当有两个泄漏分析结果相同时,LeakCanary 会根据子引用链来判断它们是否是同一个原因导致的,如果是的话,LeakCanary 会把它们归为同一组,以免重复显示同样的泄漏信息。
  • 整体大概的流程
    • image
      image

3.3 初始化流程

3.4 监听组件销毁

  • 思考一个问题:各组件的内存泄漏监听方案是怎样设计的呢?
    • Activity(ActivityWatcher):内部注册了一个 Activity 的全局生命周期监听,从而在 onDestroy() 时去追踪当前 activity 对象是否内存泄漏。
    • Fragment(FragmentAndViewModelWatcher):先注册 Act-Lifecycle 监听,然后在 onCreate() 时进行 Fragment-Lifecycle 注册监听,并在 onFragmentViewDestroyed() 与 onFragmentDestroyed() 对 view 对象与 fragment 对象进行了内存泄漏追踪。
  • 如何实现 ViewModel#onClear() 全局监听操作?
    • 在 Activity#onCreate 和 Fragment#onCreate 事件中实例化一个自定义ViewModel,在进入 ViewModel#onClear() 方法时,通过反射获取当前作用域中所有的 ViewModel 对象交给 ObjectWatcher 监控。
  • 如何实现 Service#onDestroy() 全局监听的方法?
    • 1、Hook 主线程消息循环的 mH.mCallback 回调,监听其中的 STOP_SERVICE 消息,将即将 Destroy 的 Service 对象暂存起来(由于 ActivityThread.H 中没有 DESTROY_SERVICE 消息,所以不能直接监听到 onDestroy() 事件,需要第 2 步);
    • 2、使用动态代理 Hook AMS 与 App 通信的的 IActivityManager Binder 对象,代理其中的 serviceDoneExecuting() 方法,视为 Service#onDestroy() 的执行时机,拿到暂存的 Service 对象交给 Watcher 监控。
  • 如何实现 View#remove() 全局移除监听的操作?
    • 1、Hook WMS 服务内部的 WindowManagerGlobal.mViews RootView 列表,获取 RootView 新增和移除的时机;
    • 2、检查 View 对应的 Window 类型,如果是 Dialog 或 DreamService 等类型,则在注册 View#addOnAttachStateChangeListener() 监听,在其中的 onViewDetachedFromWindow() 回调中将 View 对象交给 Watcher 监控。

3.5 监听无用对象

  • 如何判断内存泄漏的情况
    • 第一步:对于要监听的对象,使用 KeyedWeakReference 与其进行关联(初始化时传入了一个引用队列 queue),并将其保存到专门的观察 Map 中。
    • 第二步:这样当该对象被 Gc 回收时,就会出现在相应的引用队列中。然后,使用 Handler 延迟 5s后再去判断是否存在内存泄漏。
  • 在具体的判断逻辑中,会先将引用队列中出现的对象从要观察的 Map 中移除,从而避免误判。
    • 然后再判断当前要观察的对象是否存在,如果不存在,则说明没有内存泄漏;否则意味着可能出现了内存泄漏,则调用 Runtme.getRunTime().gc() 进行 GC 通知。
    • Gc之后并且等待 100ms 后再次执行判断,若该观察的对象仍然存在于观察者 Map 中,则证明该对象真的已经泄漏,此时就会根据内存泄漏的个数弹出通知或者开始 dump hprof 。

3.7 Dump内存快照

  • 在判定有内存泄漏后
    • LeakCanary调用将系统提供的Debug.dumpHprofData(File file)函数,改函数将生成一个虚拟机的内存快照,文件格式为 .hprof,这个dump文件大小通常有10+M。
    • 生成dump文件后,LeakCanary 将被泄露对象的 referenceKey 与 dump 文件 对象封装在 HeapDump 对象中,然后交给ServiceHeapDumpListener处理,在ServiceHeapDumpListener中创建 leakcanary 进程并启动服务 HeapAnalyzerService。

3.8 分析堆快照

  • LeakCanary如何分析hprof文件
    • 分析hprof文件的工作主要是在HeapAnalyzerService类中完成的。https://juejin.cn/post/6968084138125590541/#heading-13
  • 分析hprof文件的简单流程:
    • 1.解析文件头信息,得到解析开始位置
    • 2.根据头信息创建Hprof文件对象
    • 3.构建内存索引
    • 4.使用hprof对象和索引构建Graph对象
    • 5.查找可能泄漏的对象与GCRoot间的引用链来判断是否存在泄漏(使用广度优先算法在Graph中查找)

04.一些技术点思考

4.5 引用链如何生成

  • 泄漏对象的引用链是分析报告的核心信息,LeakCanary 会收集泄漏对象到 GC Root 的完整引用链信息。它如何生产的?

4.6 分析引擎的原理

4.7 提高Dump分析效率

  • Dump分析是十分耗时的行为
    • 由于 LeakCanary 分析堆快照的过程存在一定的内存消耗,整个分析过程一般会持续几十秒,对于一些性能差的机型会造成明显的卡顿甚至 ANR。
  • 如何提高Dump分析效率
    • 提供了对多进程的支持。单独开个进程,将分析工作转移到子进程中,这样就不会影响主进程。

4.8 相同问题分组

  • LeakCanary 会将相同问题重复触发的内存泄漏进行分组
    • 为了减少重复的排查工作,分组方法是按引用链的签名。引用链签名是对引用链上经过的每个对象的类型拼接后取哈希值,既然应用链完全相同,就没必要重复排查了。
  • 那么对应的泄漏签名计算公式是如何设计的?
    • 待完善

4.9 如何标记怀疑对象

  • 为了提高排查内存泄漏的效率,会标记怀疑对象
    • LeakCanary 会自动帮助我们根据对象的生命周期信息或状态信息缩小排查范围,排除原本就具有全局生命周期的对象,剩下的用 ~~~ 下划线标记为怀疑对象。
  • 标记怀疑对象的策略是如何设计的?
    • 待完善

06.其他设计说明

6.1 性能设计

  • 为什么 LeakCanary 不能用于线上
    • 每次内存泄漏以后,都会生成并解析 hprof 文件,容易引起手机卡顿等问题
    • 多次调用 GC,可能会对线上性能产生影响
    • hprof 文件较大,信息回捞成问题
  • 了解了这些问题,我们可以尝试提出一些解决方案:
    • 1.可以根据手机信息来设定一个内存阈值 M ,当已使用内存小于 M 时,如果此时有内存泄漏,只将泄漏对象的信息放入内存当中保存,不生成.hprof文件。当已使用大于 M 时,生成.hprof文件
    • 2.当引用链路相同时,可根据实际情况去重。
    • 3.不直接回捞.hprof文件,可以选择回捞分析的结果
    • 4.可以尝试将已泄漏对象存储在数据库中,一个用户同一个泄漏只检测一次,减少对用户的影响

6.2 稳定性设计

6.3 灰度设计

6.4 降级设计

6.5 异常设计优化

  • 通过ReferenceQueue+WeakReference,来判断对象是否被回收

    • WeakReference创建时,可以传入一个ReferenceQueue对象,假如WeakReference中引用对象被回收,那么就会把WeakReference对象添加到ReferenceQueue中,可以通过ReferenceQueue中是否为空来判断,被引用对象是否被回收
  • 手动调用GC后还调用了System.runFinalization();,这个是强制调用已失去引用对象的finalize方法

    • 在可达性算法中,不可达对象,也不是非死不可,这时他们处于“缓刑”阶段,要宣告一个对象真正死亡需要至少俩个标记阶段, 如果发现对象没有引用链,则会进行第一次标记,并进行一次筛选,筛选的条件是此对象是否有必要进行finalize()方法,当对象没有覆盖finalize(),或者finalize()已经调用过,这俩种都视为“没有必要执行”

参考博客

  • 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为是真强啊!
    • https://zhuanlan.zhihu.com/p/556718460
    • https://www.cnblogs.com/pengxurui/p/16614804.html
  • 一文让你彻底理解LeakCanary的工作原理
    • https://mp.weixin.qq.com/s/UfxG41HInNfv9nkDvKpcZQ
  • 「Leakcanary 源码分析」看这一篇就够了
    • https://mp.weixin.qq.com/s/n3_Zoc1UgG3Wzqv3ZrGMzA
贡献者: yangchong211
下一篇
02.Glide图片加载设计