美文网首页
Android项目复盘1

Android项目复盘1

作者: cg1991 | 来源:发表于2020-06-16 11:12 被阅读0次

个人主页:https://chengang.plus/

文章将会同步到个人微信公众号:Android部落格

1、商城项目

1.1 RecyclerView首页加载商品item内存占用过高

  • 原因:首页包含了精选,banner,秒杀,热卖列表,但是每一个ViewType没有在RecyclerView中设置各自的类型,导致缓存的时候当做一整ViewHolder缓存,从而整体内存占用过高。尤其底部的热卖列表上拉加载的时候,显得尤为显著。

1.1.1 源码追溯

RecyclerView.Recycler

void recycleViewHolderInternal(ViewHolder holder) {
    boolean cached = false;
    boolean recycled = false;
    if (forceRecycle || holder.isRecyclable()) {
        if (mViewCacheMax > 0
                && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
                | ViewHolder.FLAG_REMOVED
                | ViewHolder.FLAG_UPDATE
                | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) {
            // Retire oldest cached view
            int cachedViewSize = mCachedViews.size();
            if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
                recycleCachedViewAt(0);
                cachedViewSize--;
            }

            int targetCacheIndex = cachedViewSize;
            if (ALLOW_THREAD_GAP_WORK
                    && cachedViewSize > 0
                    && !mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)) {
                // when adding the view, skip past most recently prefetched views
                int cacheIndex = cachedViewSize - 1;
                while (cacheIndex >= 0) {
                    int cachedPos = mCachedViews.get(cacheIndex).mPosition;
                    if (!mPrefetchRegistry.lastPrefetchIncludedPosition(cachedPos)) {
                        break;
                    }
                    cacheIndex--;
                }
                targetCacheIndex = cacheIndex + 1;
            }
            mCachedViews.add(targetCacheIndex, holder);
            cached = true;
        }
        if (!cached) {
            addViewHolderToRecycledViewPool(holder, true);
            recycled = true;
        }
    }
}

RecyclerView.RecycledViewPool

public void putRecycledView(ViewHolder scrap) {
    final int viewType = scrap.getItemViewType();
    final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;
    if (mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {
        return;
    }
    scrap.resetInternal();
    scrapHeap.add(scrap);
}
SparseArray<ScrapData> mScrap = new SparseArray<>();

static class ScrapData {
    final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
    int mMaxScrap = DEFAULT_MAX_SCRAP;
    long mCreateRunningAverageNs = 0;
    long mBindRunningAverageNs = 0;
}

public ViewHolder getRecycledView(int viewType) {
    final ScrapData scrapData = mScrap.get(viewType);
    if (scrapData != null && !scrapData.mScrapHeap.isEmpty()) {
        final ArrayList<ViewHolder> scrapHeap = scrapData.mScrapHeap;
        for (int i = scrapHeap.size() - 1; i >= 0; i--) {
            if (!scrapHeap.get(i).isAttachedToTransitionOverlay()) {
                return scrapHeap.remove(i);
            }
        }
    }
    return null;
}

缓存分两个区域:

  • mCachedViews是一个List<ViewHolder>类型;
  • SparseArray<ScrapData>中的ScrapData包含了ArrayList<ViewHolder>,分ViewType存放ViewHolder,相同ViewType的ViewHolder放在一个List中,当取一个缓存出来的同时remove。

到这里可以明白,当首页整个作为一个Viewtype类型的时候,会缓存一个很大的ViewHolder对象到mCachedViews或RecycledViewPool中。

1.1.2 解决办法

  • 首页的多个视图类型拆分为不同的ViewType,分不同的视图类型加载。
  • 根据视图中商品icon图片大小,与服务端协商减小商品列表中icon图片的分辨率;同时本地存放的图片必须放置在合理的drawable文件夹中,因为文件夹对应的设备像素密度与机器屏幕像素密度越接近,内存占用会越小。

下边解释原因。

1.1.2.1 Bitmap内存计算

本地加载图片时的各种decode方法最终到了BitmapFactory.cpp的doDecode()方法中,如下:

BitmapFactory.cpp

static jobject doDecode(JNIEnv* env, std::unique_ptr<SkStreamRewindable> stream, jobject padding, jobject options) {
    if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
       const int density = env->GetIntField(options, gOptions_densityFieldID);
       const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
       const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
       if (density != 0 && targetDensity != 0 && density != screenDensity) {
          scale = (float) targetDensity / density;
       }
    }

    // Determine the output size.
    SkISize size = codec->getSampledDimensions(sampleSize);
    
    int scaledWidth = size.width();
    int scaledHeight = size.height();
    bool willScale = false;
    // Apply a fine scaling step if necessary.
    if (needsFineScale(codec->getInfo().dimensions(), size, sampleSize)) {
            willScale = true;
      scaledWidth = codec->getInfo().width() / sampleSize;
            scaledHeight = codec->getInfo().height() / sampleSize;
    }
    
    // Scale is necessary due to density differences.
    if (scale != 1.0f) {
       willScale = true;
       scaledWidth = static_cast<int>(scaledWidth * scale + 0.5f);
       scaledHeight = static_cast<int>(scaledHeight * scale + 0.5f);
    }
    
    const float sx = scaledWidth / float(decodingBitmap.width());
    const float sy = scaledHeight / float(decodingBitmap.height());
    
    SkCanvas canvas(outputBitmap);
    canvas.scale(sx, sy);
    canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);

从源码可以看出scaledWidth经过两次计算,一次是如果sampleSize不等于1的时候计算缩放宽高,等于原宽高分别除以采样倍数;另外一次是如果目标屏幕密度和当前图片所处文件夹的密度不一致的话,计算出:

scale = targetDensity / density

(比如机器当前是xxhdpi,对应480,而图片放置在xhdpi中,对应320,就会算出一个大于1的拉伸系数)

如果scale不等于1,用第一次计算的

scaledWidth * scale + 0.5,scaledHeight * scale + 0.5

可以看到分两步,一步是用最初的图片大小除以采样系数;一步是根据屏幕密度计算出来的拉伸系数然后乘以这个系数

不过具体在做缩放操作的时候缩放因子等于两次计算之后的宽高分别处以原始宽高。可见对于设置采样率可以节省部分内存。

最后实际的占用大小:

width = (originWidth / sampleSize) * (targetDensity / density) + 0.5

height = (originHeight / sampleSize) * (targetDensity / density) + 0.5

totalSize = width * height * 像素位

(targetDensity是手机实际密度,等于宽平方 + 高平方开根号,处于屏幕对角线长度,density是图片在App所处文件的密度。)

  • ARGB_8888: 每个像素4字节. 共32位,默认设置。
  • Alpha_8: 只保存透明度,共8位,1字节。
  • ARGB_4444: 共16位,2字节。
  • RGB_565:共16位,2字节,只存储RGB值。

getRowBytes()返回的是每行的像素值,乘以高度就是总的像素数,也就是占用内存的大小。

getAllocationByteCount()与getByteCount()的返回值一般情况下都是相等的。只是在图片 复用的时候,getAllocationByteCount()返回的是复用图像所占内存的大小,getByteCount()返回的是新解码图片占用内存的大小。

1.1.2.2 Bitmap内存模型
  • Android 3.0 (API level 11)

从这个版本开始,bitmap的ARGB数据(像素数据)和bitmap对象一起存在Dalvik的堆里了。这样bitmap对象和它的ARGB数据就可以同步回收了。

后续Android又引入了BitmapFactory.Options.inBitmap字段。

如果设置了这个字段,bitmap在加载数据时可以复用这个字段所指向的bitmap的内存空间。新增的这种内存复用的特性,可以优化掉因旧bitmap内存释放和新bitmap内存申请所带来的性能损耗。

但是,内存能够复用也是有条件的。比如,在Android 4.4(API level 19)之前,只有新旧两个bitmap的尺寸一样才能复用内存空间。Android 4.4开始只要旧bitmap的尺寸大于等于新的bitmap就可以复用了。

这样GC无法知道当前的内存情况是否乐观,大量创建bitmap可能不会触发到GC,而Native中bitmap的像素数据可能已经占用了过多内存,这时候就会OOM,所以推荐在bitmap使用完之后,调用recycle释放掉Native的内存。

  • Android 8.0之前

Bitmap的内存分配在dalvik heap,Bitmap中有个byte[] mBuffer,其实就是用来存储像素数据的,它位于java heap中,通过在native层构建Java Bitmap对象的方式,将生成的byte[]传递给Bitmap.java对象。

像素数据就和bitmap对象一起都分配在堆中了,一起接受GC管理,只要bitmap置为null没有被强引用持有,GC就会把它回收掉,和普通对象一样。

  • Android 8.0之后

Bitmap像素内存的分配是在native层直接调用calloc,所以其像素分配的是在native heap上,并且还引入了NativeAllocationRegistry机制。

Bitmap引入了NativeAllocationRegistry这样一种辅助自动回收native内存的机制,依然不需要用户主动回收了,当bitmap的Java对象被回收后,NativeAllocationRegistry辅助回收这个对象所申请的native内存。

  • 在RecyclerView Adapter的onViewRecycled方法中,释放图片:
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
    try {
        if (!((Activity) context).isDestroyed() && !((Activity) context).isFinishing()) {
            ImageView img = holder.itemView.findViewById(R.id.goods_img);
            img.setImageDrawable(null);
            Glide.with(context).clear(img);
        }
    } catch (Exception e) {
        MyLog.d(TAG, "recycle fail:" + e.getLocalizedMessage());
    }
}

1.2 引导页到首页中间过渡时间长

https://developer.android.com/topic/performance/vitals/launch-time?hl=zh-cn

https://zhuanlan.zhihu.com/p/91226153

https://juejin.im/entry/5b8134cdf265da434a1fce4b

1.2.1 Application到Activity加载流程

Activity启动流程

总结上图的流程就是:

Application的构造器方法——>attachBaseContext()——>onCreate()——>Activity的构造方法——>onCreate()——>配置主题中背景等属性——>onStart()——>onResume()——>测量布局绘制显示在界面上。

1.2.2 启动分析

  • 冷启动。冷启动是指应用从头开始启动:系统进程在冷启动后才创建应用进程。发生冷启动的情况包括应用自设备启动后或系统终止应用后首次启动。这种启动给最大限度地减少启动时间带来了最大的挑战,因为系统和应用要做的工作比在其他启动状态下更多。
image
  • 热启动。应用的热启动比冷启动简单得多,开销也更低。在热启动中,系统的所有工作就是将您的 Activity 带到前台。如果应用的所有 Activity 都还驻留在内存中,则应用可以无须重复对象初始化、布局扩充和呈现。

  • 温启动。温启动涵盖在冷启动期间发生的操作的一些子集;同时,它的开销比热启动多。有许多潜在状态可视为温启动。例如:

用户退出您的应用,但之后又重新启动。进程可能已继续运行,但应用必须通过调用 onCreate() 从头开始重新创建 Activity。
系统将您的应用从内存中逐出,然后用户又重新启动它。进程和 Activity 需要重启,但传递到 onCreate() 的已保存实例状态包对于完成此任务有一定助益。

1.2.3 测量启动时间

adb shell am start -W [packageName]/[packageName.MainActivity]

输出如下:

E:\data_parse>adb shell am start -S -W com.xx.xx.xx/.activity.MainActivity
Stopping: com.xx.xx.xx
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.xx.xx.xx/.activity.MainActivity }
Status: ok
Activity: com.xx.xx.xx/.activity.MainActivity
ThisTime: 1136
TotalTime: 75246
WaitTime: 1179
Complete
  • ThisTime : 最后一个 Activity 的启动耗时(例如从 LaunchActivity --> MainActivity「adb命令输入的Activity」 , 只统计 MainActivity 的启动耗时)
  • TotalTime : 启动一连串的 Activity 总耗时.(有几个Activity 就统计几个)
  • WaitTime : 应用进程的创建过程 + TotalTime .
image

图片来源https://juejin.im/entry/5b8134cdf265da434a1fce4b

  • 在第①个时间段内,AMS 创建 ActivityRecord 记录块和选择合理的 Task、将当前Resume 的 Activity 进行 pause.
  • 在第②个时间段内,启动进程、调用无界面 Activity 的 onCreate() 等、 pause/finish 无界面的 Activity.
  • 在第③个时间段内,调用有界面 Activity 的 onCreate、onResume.

1.2.4 解决问题

从两方面入手,想办法缩短Application消耗的时间;缩短Activity消耗的时间。

1.2.4.1 请求数据统一整合

我们的项目中有各种SDK的初始化,包括友盟,百川,开普勒,Glide,分享等。

  • 第一步,将之前各个负责人的数据请求框架集合到一个类中,综合统一调度。统一Json解析方法,所有的json解析挪入IO线程处理,序列化成类之后统一返回。

json解析的过程存在json字符遍历,而商城类项目从服务端返回的数据上百k,有些json结构非常复杂,比较耗时

  • 第二步,首页数据分多个接口提供,每一个接口一个负责人,导致每一个负责人在首页启动的时候都去请求数据,没有做到统一调度,而且部分页面的数据在IO线程请求完毕之后,调度到UI线程做解析,明显拖慢了加载速度
  • 第三步,对首页统一返回的数据做拆分,对优先级比较低的数据,单独做一个接口,待首页加载完成之后或页面展示时再展现
  • 第四步,将在Application中预先加载首页数据挪到闪屏页加载,充分利用闪屏页延迟等待的2000ms的时间
  • 第五步,将不必要的SDK初始化挪到首页展示完成之后初始化或使用前初始化
1.2.4.2 视图xml优化
  • 将闪屏页的图片从xml的ImageView android:src中移除,加快xml inflate速度,并在Activity的onCreate方法中通过图片工具类加载,这样有一定概率通缓存中加载,避免每次都需要解码。而解码又需要消耗一些内存,还可能导致OOM。
  • 检查闪屏页和首页的xml视图层级,将过渡绘制的页面提出来重点优化,对不必要的层级删除,对不必要的背景设置为null或透明。
  • 对于自定义的View,重点检查onDraw方法,避免对象的创建
1.2.4.3 其他一些优化
  • SharedPreference不能写入大量数据作为value,只能写入一些flag等标识性变量,因为SharedPreference在初始化的时候会从Disk加载数据,这里是阻塞式的。而如果之前存有大量的数据在里面会导致阻塞主线程。在获取Editor往里面提交数据的时候,又会去等待SharedPreference初始化完成。所以这里一方面导致SharedPreference初始化慢,IO操作会阻塞主线程;另一方面又有可能因为等待引起ANR。

SharedPreferencesImpl

private final Object mLock = new Object();

private void startLoadFromDisk() {
    synchronized (mLock) {
        mLoaded = false;
    }
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            loadFromDisk();
        }
    }.start();
}

    @Override
public Editor edit() {
    synchronized (mLock) {
        awaitLoadedLocked();
    }

    return new EditorImpl();
}

public String getString(String key, @Nullable String defValue) {
    synchronized (mLock) {
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

在这里可以看到,加锁的对象是mLock,当loadFromDisk方法执行完毕之后,才会执行mLock.notifyAll();,至此,其他的代码才会获得执行时机。尤其是后续edit,以及put/get操作的时候。

1.3 flutter版本Widget刷新时间长,刷新频繁

http://fluttersamples.com/

当在StatefulWidget中调用setState的时候,会导致当前Widget下所有Widget树刷新,这种情况如果遇上复杂的布局,肯定是不可想象的,先看看调用setState的时候发生了什么,伪代码如下:

@protected
void setState(VoidCallback fn) {
    final dynamic result = fn() as dynamic;
    _element.markNeedsBuild();
    
     scheduleFrame();
     
     void handleDrawFrame() {};
     
     void drawFrame() {};
     
     rebuild();
     
     preformRebuild();
     
     build();
     
     updateChild();
     
     update();
}

可以看到最终会导致重新请求渲染帧,更新视图。

解决方案是:

  • 将在顶级视图的setState方法下放到各个需要更新的子视图中,由子视图控制刷新
  • 将不必要的StatefulWidget改成StatelessWidget,避免不必要的刷新
  • 当子视图需要状态更新但是层级较多时,引入InheritedWidget。看看InheritedWidget的大致工作流程:
//第一步
@override
void _updateInheritance() {
    final Map<Type, InheritedElement> incomingWidgets = _parent?._inheritedWidgets;
    if (incomingWidgets != null)
      _inheritedWidgets = HashMap<Type, InheritedElement>.from(incomingWidgets);
    else
      _inheritedWidgets = HashMap<Type, InheritedElement>();
    _inheritedWidgets[widget.runtimeType] = this;
}

//第二步
@override
T dependOnInheritedWidgetOfExactType<T extends InheritedWidget>({Object aspect}) {
    final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[T];
    if (ancestor != null) {
      return dependOnInheritedElement(ancestor, aspect: aspect) as T;
    }
    _hadUnsatisfiedDependencies = true;
    return null;
}

@override
InheritedWidget dependOnInheritedElement(InheritedElement ancestor, { Object aspect }) {
    _dependencies ??= HashSet<InheritedElement>();
    _dependencies.add(ancestor);
    ancestor.updateDependencies(this, aspect);
    return ancestor.widget;
}

@override
void updateDependencies(Element dependent, Object aspect) {
    setDependencies(dependent, null);
}

@protected
void setDependencies(Element dependent, Object value) {
    _dependents[dependent] = value;
}

//第三步
@protected
void updated(covariant ProxyWidget oldWidget) {
    notifyClients(oldWidget);
}

@override
void notifyClients(InheritedWidget oldWidget) {
    for (final Element dependent in _dependents.keys) {
      notifyDependent(oldWidget, dependent);
    }
}

void notifyDependent(covariant InheritedWidget oldWidget, Element dependent) {
    dependent.didChangeDependencies();
}

@mustCallSuper
void didChangeDependencies() {
    markNeedsBuild();
}
  • 第一步,Element初始化阶段。

在这个阶段每一个Element在mount的过程中会调用_updateInheritance方法,生成一个HashMap _inheritedWidgets。这里比较取巧的是,当父类已经存在的时候,直接在父类的_inheritedWidgets里面追加,而runType就是他的key,所以可以轻松找到InheritedWidget。

  • 第二步,从InheritedWidget获取数据阶段。

InheritedWidget的子Widget调用它对外暴露的of方法时,通过调用dependOnInheritedWidgetOfExactType方法返回InheritedWidget自身。这里从第一步的_inheritedWidgets中通过runType找到这个对象,然后调用它的setDependencies方法,将子Widget的Element作为依赖项加入到一个HashSet _dependents中。

  • 第三步,通知子Element更新。

当InheritedWidget的数据发生变化时,会触发渲染树更新,当调用它的update方法更新Element的时候,会遍历上一步_dependents中保存的依赖Element,并重建这些Element。

透过以上步骤,我们可以发现不论InheritedWidget与需要依赖它数据的Widget中间隔了多少层级,只要InheritedWidget数据发生变化,都能通知依赖它的Widget重绘。

相关文章

  • Android项目复盘1

    个人主页:https://chengang.plus/文章将会同步到个人微信公众号:Android部落格 1、商城...

  • 复!说明

    1 、复盘分项目复盘和角色复盘两部分 2、项目复盘是对 整个项目进行复盘 3、角色复盘是对自己承担角色进行复盘 4...

  • 复盘Day 1 2019.12.12

    复盘人:黑带大花猫 复盘项目:《财富自由之路》 复盘时间:2019.12.12 Day 1 目的目标(项目起初的目...

  • 专项复盘模板

    复盘人: 复盘项目: 复盘期间: 目的目标(写下项目起初的目的,设计的目标与阶段性目标或里程碑) 1. 项目目...

  • 如何做好项目复盘

    如何做好项目复盘 1 什么时间复盘 定期复盘不定期复盘其他有必要时 2 与会人员 项目经理、需求分析师、商务技术、...

  • 如何做项目复盘

    1.复盘时间 小事及时复盘,大事阶段性复盘,项目结束后全面复盘 2.复盘步骤 回顾目标,评估结果,分析原因,总结经...

  • 复盘一个项目顶你做10个项目

    复盘除了可以用在自我提升领域,更可以用在工作项目上。学会对项目进行复盘,复盘一个项目,顶你做十个项目。 在项目复盘...

  • 45/100简约生活,减少焦虑(七天训练营04)

    复盘人:坚持100天复盘的牛牛 复盘项目:简约生活,减少焦虑 复盘期间:2020040607 目的目标(写下项目起...

  • 46/100简约生活,减少焦虑(七天训练营05)

    复盘人:坚持100天复盘的牛牛 复盘项目:简约生活,减少焦虑 复盘期间:202004008 目的目标(写下项目起初...

  • 61/100击破痛点——时间管理复盘

    复盘人:坚持100天复盘的牛牛 复盘项目:管理自己 自如生活 复盘期间:20200707 目的目标(写下项目起初的...

网友评论

      本文标题:Android项目复盘1

      本文链接:https://www.haomeiwen.com/subject/cmplxktx.html