前言

程序员的成长史就是一部不断踩坑不断填坑的血泪史,踩的坑多了,也就有了路。因为 Android 发展迅速迭代频率非常快、碎片化严重,在开发过程中或多或少会有一些坑,为了避免再次入坑,所以总结记录一下。

一、LiveData 踩坑及原理分析

作为 Google 主推 Jetpack 组件的一员大将,LiveData 一经推出便获得广大程序员的喜爱,拥有可观察、生命周期感知的特点,因为项目中有大量使用 LiveData,所以本文着重从源码角度分析 LiveData 的原理以及一些在使用中容易出错的特性。

说明:

LiveData 是一种可观察的数据存储器类。与常规的可观察类不同,LiveData 具有生命周期感知能力,意指它遵循其他应用组件(如 Activity、Fragment 或 Service)的生命周期。这种感知能力可确保 LiveData 仅更新处于活跃生命周期状态的应用组件观察者。

优势:
  • 确保界面符合数据状态
    LiveData 遵循观察者模式。当生命周期状态发生变化时,LiveData 会通知 Observer 对象。您可以整合代码以在这些 Observer 对象中更新界面。观察者可以在每次发生更改时更新界面,而不是在每次应用数据发生更改时更新界面。
  • 不会发生内存泄漏
    观察者会绑定到 Lifecycle 对象,并在其关联的生命周期遭到销毁后进行自我清理。
  • 不会因 Activity 停止而导致崩溃
    如果观察者的生命周期处于非活跃状态(如返回栈中的 Activity),则它不会接收任何 LiveData 事件。
  • 不再需要手动处理生命周期
    界面组件只是观察相关数据,不会停止或恢复观察。LiveData 将自动管理所有这些操作,因为它在观察时可以感知相关的生命周期状态变化。
  • 数据始终保持最新状态
    如果生命周期变为非活跃状态,它会在再次变为活跃状态时接收最新的数据。例如,曾经在后台的 Activity 会在返回前台后立即接收最新的数据。
  • 适当的配置更改
    如果由于配置更改(如设备旋转)而重新创建了 Activity 或 Fragment,它会立即接收最新的可用数据。
  • 共享资源
    您可以使用单一实例模式扩展 LiveData 对象以封装系统服务,以便在应用中共享它们。LiveData 对象连接到系统服务一次,然后需要相应资源的任何观察者只需观察 LiveData 对象。
使用 LiveData 需要注意的地方
1. LiveData 只能在主线程中订阅和移除观察者

LiveData 不允许在子线程中订阅和移除观察者,如果在子线程中订阅则会抛出异常,关键代码如下:

@MainThread
    public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {
        assertMainThread("observe");
        if (owner.getLifecycle().getCurrentState() == DESTROYED) {
            // ignore
            return;
        }
        LifecycleBoundObserver wrapper = new LifecycleBoundObserver(owner, observer);
        ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
        if (existing != null && !existing.isAttachedTo(owner)) {
            throw new IllegalArgumentException("Cannot add the same observer"
                    + " with different lifecycles");
        }
        if (existing != null) {
            return;
        }
        owner.getLifecycle().addObserver(wrapper);
    }
    @MainThread
    public void observeForever(@NonNull Observer<? super T> observer) {
        assertMainThread("observeForever");
        AlwaysActiveObserver wrapper = new AlwaysActiveObserver(observer);
        ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
        if (existing instanceof LiveData.LifecycleBoundObserver) {
            throw new IllegalArgumentException("Cannot add the same observer"
                    + " with different lifecycles");
        }
        if (existing != null) {
            return;
        }
        wrapper.activeStateChanged(true);
    }

    @MainThread
    public void removeObserver(@NonNull final Observer<? super T> observer) {
        assertMainThread("removeObserver");
        ObserverWrapper removed = mObservers.remove(observer);
        if (removed == null) {
            return;
        }
        removed.detachObserver();
        removed.activeStateChanged(false);
    }

    static void assertMainThread(String methodName) {
        if (!ArchTaskExecutor.getInstance().isMainThread()) {
            throw new IllegalStateException("Cannot invoke " + methodName + " on a background"
                    + " thread");
        }
    }

可以发现每个方法都调用了 assertMainThread 方法,判断当前线程不是主线程时抛出 IllegalStateException 异常。

2. LiveData 的 setValue 方法只能在主线程中使用

LiveData 不允许在子线程中使用 setValue 方法,否则会抛异常。代码如下:

@MainThread
    protected void setValue(T value) {
        assertMainThread("setValue");
        mVersion++;
        mData = value;
        dispatchingValue(null);
    }



3. LiveData 数据丢失问题

由于 LiveData 数据始终保持最新状态的特性,LiveData 只会保留最新一条数据到缓存中,在平时开发过程中,常常发现数据丢失的情况。下面编写伪代码来验证一下:

package com.example.jetpack;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;

import android.os.Bundle;
import android.util.Log;

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "MainActivity";
    private MyViewModel viewModel;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        viewModel = new ViewModelProvider(this).get(MyViewModel.class);
        viewModel.getLiveData().observe(this, new Observer<String>() {
            @Override
            public void onChanged(String data) {
                Log.d(TAG, "接收 data : " + data);
            }
        });
    }
    @Override
    protected void onStart() {
        super.onStart();
        Log.d(TAG, "恢复界面");
    }

    @Override
    protected void onStop() {
        super.onStop();
        Log.d(TAG, "离开界面");
    }
}
class MyViewModel extends ViewModel {
    private MutableLiveData<String> liveData = new MutableLiveData<String>();

    public LiveData<String> getLiveData() {
        return liveData;
    }

    public void setValue(String value) {
        liveData.setValue(value);
    }

    public void postValue(String value) {
        liveData.postValue(value);
    }
}
  • 在UI可见时,调用
    liveData.postValue(“a”);
    liveData.setValue(“b”);
    会先收到"b",后收到"a"
  • android Blurry 如何使用_android Blurry 如何使用

  • 在UI不可见时,调用
    liveData.postValue(“a”);
    liveData.setValue(“b”);
    当UI可见时,只会收到"a",因为setValue先执行,之后被postValue更新掉
  • android Blurry 如何使用_数据_02

  • 在UI可见时,调用
    liveData.setValue(“a”);
    liveData.setValue(“b”);
    会按顺序收到"a","b"
  • android Blurry 如何使用_android_03

  • 在UI可见时,调用
    liveData.postValue(“a”);
    liveData.postValue(“b”);
    只会收到 "b"
  • android Blurry 如何使用_android Blurry 如何使用_04

  • 在UI不可见时,调用
    liveData.setValue(“a”);
    liveData.setValue(“b”);
    当UI可见之后,只会收到 "b"
  • android Blurry 如何使用_数据_05

  • 在UI不可见时,调用
    liveData.postValue(“a”);
    liveData.postValue(“b”);
    当UI可见之后,只会收到 "b"
  • android Blurry 如何使用_UI_06

  • 在UI不可见时,调用
    liveData.setValue("a);
    liveData.postValue(“b”);
    当UI可见之后,只会收到 "b"
  • android Blurry 如何使用_android_07

  • 在UI不可见时,调用
    liveData.postValue(“a”);
    liveData.setValue(“b”);
    当UI可见之后,只会收到"a"
  • android Blurry 如何使用_android Blurry 如何使用_08

  • 详细分析请移步我的另一篇博客:Jetpack 之 LiveData 入坑
4. LiveData 的 observeForever 和 removeObserver 方法要配套使用

在某些情况下,我们需要在页面不可见时也想收到数据,则会使用 observeForever 订阅被观察者对象,这时观察者对象不会自动移除引用,会导致内存泄漏问题,就需要我们在对应的生命周期下调用 removeObserver 方法移除引用。

5. 在 Fragment 中出现 LiveData 同样数据多次回调的问题

这是由于在使用 Fragment 时,可能会出现 LiveData 多次订阅的情况,当 LiveData 中有数据时,在重新订阅后就会发送一次数据,然后有时我们一个数据只需要接收一次。只针对这个问题,Google 一位大神在 Stack Overflow 实现了一个复写类 SingleLiveEvent,其中的机制是用一个原子 AtomicBoolean 记录一次 setValue。在发送一次后在将 AtomicBoolean 设置为 false,阻止后续前台重新触发时的数据发送。

import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;

import java.util.concurrent.atomic.AtomicBoolean;

public class SingleLiveEvent<T> extends MutableLiveData<T> {
    private final AtomicBoolean mPending = new AtomicBoolean(false);
    @Override
    public void observe(@NonNull LifecycleOwner owner, @NonNull final Observer<? super T> observer) {
        super.observe(owner, new Observer<T>() {
            @Override
            public void onChanged(@Nullable T t) {
                if (mPending.compareAndSet(true, false)) {
                    observer.onChanged(t);
                }
            }
        });
    }

    @MainThread
    public void setValue(@Nullable T t) {
        mPending.set(true);
        super.setValue(t);
    }

    /**
     * Used for cases where T is Void, to make calls cleaner.
     */
    @MainThread
    public void call() {
        setValue(null);
    }
}

二、代码使用不当造成的坑以及解决方案

1. 同一个程序内的多个进程之间使用 SharedPreferences 不安全:

问题现象: 在同一个程序内使用多进程时,在不同进程间使用 SharedPreferences 操作数据会导致 SP 中的数据随机丢失的情况(获取到的值为空);

原因分析: 虽然API中提供了 Context.MODEMULTIPROCESS 模式打开 SP 文件,但在 官方文档 中已有说明:“This class does not support use across multiple processes”,因为 SP 在多进程间操作是不安全的,并行操作时会导致写冲突。

解决方案: Github上有个开源项目 Tray (https://github.com/grandcentrix/tray),专门针对在多进程中使用 SP 不安全的问题提供的解决方案。

2. 使用字库 Typeface 时耗时导致卡顿:

问题现象: 在使用自定义字体的页面,进入慢;

原因分析: 使用 Typeface 初始化字体很耗时,至少需要 100ms(不同文件耗时不一样)以上的时间。

解决方案: 如果在 Activity 的 onCreate 方法中初始化 Typeface,会导致进入 Activity 慢,出现黑屏/白屏现象,所以应该尽量在非 UI 线程中做自定义字体的初始化操作,并且使用单例模式缓存字体库实例。

3. Activity 在没有完全显示/已退出的情况下显示 PopupWindow 异常:

问题现象: 进入 Activity 界面直接报错,log 异常显示为:“Unable to add window – token null is not valid”;

原因分析: 原因是在 Activity 的 onCreate 方法中直接显示了 PopupWindow 导致,PopupWindow 的显示是依附在某一个View上面的(showAtLocation 方法第一个参数为需要依附的 view ),在 Activity 没有完全显示时 , PopupWindow 无法依附在该 View 上,如果在此时显示 PopupWindow 会导致上面的异常,同样在退出 Activity 后也不能正常显示 PopupWindow。

解决方案: 在开发过程中需要考虑通过异步显示 PopupWindow 或使用 DialogFragment 代替 PopupWindow ,避免 PoupWindow 显示报异常的问题。

4. 两个 Activity 频繁切换:

问题现象: 在两个 Activity 中的生命周期中对同一个实例进行操作,连续进入、退出某一个 Activity ,会出现 Activity Crash 掉的现象;

原因分析: 在 Activity 的 onCreate 做的初始化操作(打开文件),在 onDestory 做的销毁操作(关闭文件);退出 Activity 后 onDestory 并没有立即调用,再次快速进入该 Activity 时,该 Activity 是另外一个实例,并且首先调用了新 Activity 的 onCreate 方法之后再才调用上个 Activity 实例的 onDestory 方法,导致文件刚被打开就关闭了,在程序使用数据时 Crash 掉;

解决方案: 对于这种问题只能尽量不要在 Activity 的系统回调方法中做资源初始化和释放的操作,比如涉及到 IO 操作的情况,在使用的时候才打开,使用完后立即关闭;

5. 透明主题导致 Activity 生命周期回调的变化:

问题现象: 从当前 Activity 跳转到其它 Activity 时,当前 Activity 的 onStop 方法并没有调用;

原因分析: 给当前 Activity 设置为透明主题导致,通过添加打印跟踪发现,从该 Activity 跳转到其它 Activity 时,该 Activity 的 onStop 方法不会执行;

解决方案: 谨慎使用透明主题,如果必须要为 Activity 设置为透明主题,不要在 onStop 方法中做任何操作,因为该方法并不会被调用。透明主题存在很多问题,比如在设置为透明主题的界面按 Home 按键时,会存在界面刷不干净的情况。

6. 不要通过 Bundle 传递很大块的数据:

问题现象: 从目录界面跳转到内容显示界面,出现随机崩溃的现象,报的异常是:TransactionTooLargeException;

原因分析: 跟踪发现如果通过 Bundle 传递太大块(>1M)的数据会在程序运行时报 TransactionTooLargeException 异常,具体原因可以上官网查看,大致意思是传递的序列化数据不能超过 1M,否则会报 TransactionTooLargeException 异常;之所以随机是因为每次传递的数据大小不一样。

解决方案: 如果你在不同组件之间传递的数据太大,甚至超过了 1M,为了提高效率和程序的稳定性,建议通过持久化的方式传递数据,即在传递方写文件,在接收方去读取这个文件;

7. 数据库升级中的坑:

问题现象: 在数据库的某个表中增加/修改了某个字段后,程序在运行时崩溃掉了;或者在增加字段时修改了数据库的版本号,但程序升级后,原来的数据丢失了;

原因分析: SQlite 数据库升级时需要修改 OpenHelper 中的版本号,并且数据库升级会删掉原来数据库中的数据,需要手动将原数据库中的数据拷贝到高版本的数据库中;

解决方案: 做好数据库升级的恢复工作,避免出现崩溃、数据丢失的情况。

8. 程序在未启动的情况下,静态注册的广播无法收到消息:

问题现象: 程序添加了对开机广播的监听,但无法接收到;

原因分析: 这个问题只有在程序安装但没有启动时才会出现,只要程序启动过一次后就不会有这个问题。并且只有在 Android 3.1及以上的版本才会出现,具体原因是:从 Android3.1 开始,新安装的程序会被置于 “stopped” 状态,并且只有在至少手动启动这个程序一次后该程序才会改变状态,能够正常接收到指定的广播消息。Android 这样做的目的是防止广播无意或者不必要地开启未启动的 APP 后台服务。也就是说在 Android3.1 及以上的版本,程序在未启动的情况下通过应用自身完成一些操作是不可能的,但 Android 提供了一种借助其它应用发送指定 Flag 广播的方式,达到应用在未启动的情况下仍然能够收到消息的效果。从Android 3.1开始,系统给 Intent 定义了两个新的 Flag,分别为 FLAGINCLUDESTOPPEDPACKAGES(表示包含未启动的 App )和 FLAGEXCLUDESTOPPEDPACKAGES(表示不包含未启动的 App ),用来控制 Intent 是否要对处于停止状态的 App 起作用。

解决方案: 只能借助其它应用给自己发送带 FLAG_INCLUDESTOPPEDPACKAGES 标志的广播才能实现在程序未启动的情况下接收到广播;

9. android:windowBackground 导致的过渡绘制问题:

问题现象: 界面的布局已无法进一步优化,但仍然存在过渡绘制的问题;

原因分析: window 存在默认的背景,会增加过渡绘制的可能。Activity 是依附在 Window 上的,如果给 Activity 设置了背景,并且没有去掉 window 的背景,很容易导致过渡绘制;这里还有一个坑,有的应用为了避免程序冷启动时出现黑屏/白屏的问题,在主题中给 window 设置了背景,并且在 Activity 的布局中给 Activity 也设置了背景,这会导致当前界面存在两个背景,占用了双倍的内存,并且还会有过渡绘制的问题。程序启动黑屏应该去优化性能问题,而不是采用给 window 设置背景的方式;

解决方案: 可以通过给 Activity 自定义主题,在主题中去掉 window 的默认背景,即:@null;

10. Fragment isAdded:

问题现象: 程序随机崩溃;

原因分析: 跟踪异常 log 发现,是因为 Fragment 没有完全显示或者已经离开 Fragment 的情况下,导致的异常,这类异常的主要原因是:使用 Fragment 时,通过异步操作(比如回调、非UI线程等)更新 Fragment 的状态,但此时 Fragment 没有完全显示或者已经离开 Fragment ;

解决方案: 在调用 Fragment 的方法之前,强烈建议调用 isAdded 方法判断 Fragment 是否依附在 Activity 上,避免出现异常。

11. Fragment hide、show 被调用时,生命周期不会回调:

问题现象: 同一界面不同 Fragment 之间切换时,并没有触发一些动态效果,比如播报音频、显示切换动画等;
  
原因分析: Fragment hide、show 被调用时,系统并不会调用 Fragment 的生命周期回调;

解决方案: 不同 Fragment 之间切换时,主动调用各个 Fragment 的生命周期回调;

12. 同一设备上,相同程序的图片放在不同 drawable 文件夹下,占用内存不一样:

问题现象: 程序刚启动就占用了很高的内存;

原因分析: 图片放置位置不合理导致的,程序在不同的设备中运行时,会根据设备的分辨率和屏幕密度去从与之分辨率匹配的资源文件夹中取图片,如果没有对应分辨率的文件夹,则从相近分辨率的文件夹中取,但图片会被拉伸到当前设备屏幕的宽高,所以会存在图片被放大或者缩小的问题,导致占用内存会随之变化,具体可以查看这篇博客关于 Android 中图片大小、内存占用与 drawable 文件夹关系的研究与分析: https://www.jianshu.com/p/312511edf94f

解决方案: 为了减少 UI 的工作量,并且减少 APK 的内存占用的方法是让 UI 出一套高分辨率版本的图片,放在 hdpi 文件夹下。

13. Toast 连续显示时长时间不消失:

问题现象: 多个 Toast 同时显示时,Toast 一直显示不消失,退出程序了仍然显示;

原因分析: 看 Toast 的源码可以发现,同时显示多个 toast 时是排队显示的,所以才会出现同时显示多个 Toast 时很久都不消失的情况;

解决方案: 这属于体验问题,很多应用都存在。建议定义一个全局的 Toast 对象,这样可以避免连续显示 Toast 时不能取消上一次Toast消息的情况(如果你有连续弹出 Toast 的情况,避免使用 Toast.makeText );

14. AS 中依赖包的动态自动更新:

问题现象: 在 AS 中,因为依赖包会频繁更新,为了方便会常常在依赖包的版本号写上 “+”,比如:compile ‘com.android.support:appcompat-v7:23.0.+’,会引入未经测试的依赖包可能导致程序不兼容。

原因分析: 依赖包版本号使用 “+” 的形式在 AS 编译的时候会去检查依赖包的最新版本,把最新版未进行测试过的依赖包引入项目中。

解决方案: 这种方法需要谨慎使用,否则会因为依赖包的变动导致你的项目不稳定:Don’t use dynamic versions for your dependencies,不要偷懒,在依赖包后面跟上具体的版本号。

15. AS 中同一个工程 module 太多导致编译慢:

问题现象: 编译一个工程要好几分钟,特别是 clean 的时候,经常 10 分钟以上;

原因分析: 其实这个很好理解,每个 module 中都有一个 build.gradle,编译的时候,每个 module 的build.gradle 中的 task 都需要执行,所以编译时间会很长。

解决方案: 要解决这个问题很简单,将不经常变动的 module 打包成 aar,主工程依赖 aar 而不是 module,这样避免了每次都需要重新编译 module 的情况。

16. 频繁的 GC 操作导致程序卡顿:

问题现象: 通过 AS Monitor 观察应用运行过程中的内存抖动厉害,通过 GPU 呈现模式观察每一帧的曲线差别很大,整体感受程序运行时不流畅;

原因分析: 在2.3之前 GC 操作是不能并发进行的,也就是系统正在进行 GC 程序就只能阻塞住等待 GC 结束,在 2.3 之后 GC 操作改成了并发的方式进行,GC 过程中不会影响程序的正常运行,但在 GC 操作的开始和结束还是会短暂阻塞一段时间,所以频繁的 GC 会导致使用应用的过程中卡顿。

解决方案: 为了应用在使用过程中更流畅,需要尽量减少触发 GC 操作,这涉及到性能优化,对于静态代码的分析,AS 已经很强大了,可以使用 Android Studio 的 Analyze→Inspect Code… 进行分析;

17. TextView 的 setText 方法不能直接传 int 值:

问题现象: 程序运行时报 “NotFoundException” 异常;
  
原因分析: TextView.setText(int value) 会直接当作字符串资源 ID 处理,在 xml 文件中没有找到 id 对应的字符串就会抛异常;
  
解决方案: 给 TextView 设置文本的时候一定要转成 String 或者 Charsequence 类型,避免 TextView 将 setTex t中的参数当做字符串资源 ID 处理,去加载字符串资源,因为字符串在 xml 文件中不存在导致程序运行时崩溃。

18. 通过反射访问方法和字段的效率大不一样:

问题现象: 程序运行卡、慢;

原因分析: 在一个循环中使用到了反射,并且是调用的反射方法,改成反射字段后,卡、慢的现象得到明显的改善;

解决方案: 通过反射修改或者获取类中的某个属性时,强烈建议使用访问字段的方式,不要使用访问方法的方式,这两者之间的效率相差很大,亲测访问方法是访问字段耗时的 1.5 倍,具体情况和类的复杂度有关。

19. ViewPager 使用动画导致点击事件混乱

问题现象: ViewPager.setPageTransformer 设置 DepthPageTransformer 动画后,当前页面的点击事件会透过页面执行到后面的页面中。

原因分析: 看下面代码,这一段是后一个页面淡出动画,从代码中可以看到,当后一页面淡出时,只是设置了 alpha 值,并没有使这个页面失效,因此两个页面就重叠在一起了,

public class DepthPageTransformer implements ViewPager.PageTransformer {
        private static final float MIN_SCALE = 0.75f;

        public void transformPage(View view, float position) {
            int pageWidth = view.getWidth();

            if (position < -1) { // [-Infinity,-1)
                // This page is way off-screen to the left.
                view.setAlpha(0f);

            } else if (position <= 0) { // [-1,0]
                // Use the default slide transition when moving to the left page
                view.setAlpha(1f);
                view.setTranslationX(0f);
                view.setScaleX(1f);
                view.setScaleY(1f);

            } else if (position <= 1) { // (0,1]
                // Fade the page out.
                view.setAlpha(1 - position);

                // Counteract the default slide transition
                view.setTranslationX(pageWidth * -position);

                // Scale the page down (between MIN_SCALE and 1)
                float scaleFactor = MIN_SCALE
                        + (1 - MIN_SCALE) * (1 - Math.abs(position));
                view.setScaleX(scaleFactor);
                view.setScaleY(scaleFactor);

            } else { // (1,+Infinity]
                // This page is way off-screen to the right.
                view.setAlpha(0f);
            }
        }
    }

解决方案: 页面显示时显示,页面淡出时隐藏,如下:

} else if (position <= 1) { // (0,1]
                //页面进入时,显示页面
	        	view.setVisibility(View.VISIBLE);
                // Fade the page out.
                view.setAlpha(1 - position);           
                // Counteract the default slide transition
                view.setTranslationX(pageWidth * -position);

                // Scale the page down (between MIN_SCALE and 1)
                float scaleFactor = MIN_SCALE
                        + (1 - MIN_SCALE) * (1 - Math.abs(position));
                view.setScaleX(scaleFactor);
                view.setScaleY(scaleFactor);
                //viewPage 后一个界面会与当前页重叠,当淡出时候,隐藏
	            if(position == 1){
	            	view.setVisibility(View.GONE);
	            }
            } else { // (1,+Infinity]
                // This page is way off-screen to the right.
                view.setAlpha(0f);
            }
20. 当 Acitivity 为 singleTask 模式时,onActivityResult 无法执行的问题

问题现象: Acitivity 的 onActivityResult 回调方法不执行。

原因分析: 通过查看文档发现有这么一段说明:Note that this method should only be used with Intent protocols thatare defined to return a result. In other protocols (such as ACTION_MAIN orACTION_VIEW), you may not get the result when you expect. For example,if the activity you are launching uses the singleTask launch mode, it will not run in your task and thus you willimmediately receive a cancel result.
大概意思是如果要启动的 Acitivity 使用 singleTask 模式,则 onActivitResult() 立即回调且 resultCode 为RESULT_CANCEL , 具体分析请查看这一篇博客 startActivityForResult 和 singleTask不能使用的问题

解决方案: 把启动模式修改为其他模式或使用其他传值方式

21. 遍历集合时往集合中添加或移除数据

问题现象: 程序抛出 ConcurrentModificationException 异常导致程序崩溃。

// 例
for (String item : set) {
    if ("a".equals(item)) {
        set.remove(item);
    }
}

原因分析: 集合长度改变会改变变量 modCount 值,在集合遍历过程中检查 modCount 和预期值 expectedModCount 不同会抛出 ConcurrentModificationException 异常。

解决方案: 通过迭代器遍历

// 例
Iterator iterator = set.iterator();
String item;
while(iterator.hasNext()){
    item = (String) iterator.next();
    if ("bbbbbbb".equals(item)) {
        iterator.remove();
    }
}
22. Fragment 使用 RecyclerView 内存泄漏

问题现象: 在 Fragment 使用 RecyclerView 时,Adapter 使用不当会导致内存泄漏
  
原因分析: 通过查看 RecyclerView 的代码,大概就是有个 Observer 用来观察 Adapter 的数据变化。因为用的是非静态内部类,这个 Observer 和这个 RecyclerView 就会有互相引用。 Adapter 里面的 Observable 也是个内部类,而 Observable 会把传进来的 Observer 存起来。如果我们 Adapter 放在一个 Fragment 里面,然后这个 Fragment 放到 Backstack 里面去的时候只有 View 是销毁的,非 View 的部分是会保留的。这就造成了 Adapter 不会被自动释放,从而导致 RecyclerView 不被释放(尽管 onDestroyView 调用了),最后造成内存泄漏。

public void setAdapter(Adapter adapter) {
        // bail out if layout is frozen
        setLayoutFrozen(false);
        setAdapterInternal(adapter, false, true);
        requestLayout();
    }
     private void setAdapterInternal(Adapter adapter, boolean compatibleWithPrevious,
            boolean removeAndRecycleViews) {
        if (mAdapter != null) {
            mAdapter.unregisterAdapterDataObserver(mObserver);
            mAdapter.onDetachedFromRecyclerView(this);
        }
        if (!compatibleWithPrevious || removeAndRecycleViews) {
            removeAndRecycleViews();
        }
        mAdapterHelper.reset();
        final Adapter oldAdapter = mAdapter;
        mAdapter = adapter;
        if (adapter != null) {
            adapter.registerAdapterDataObserver(mObserver);
            adapter.onAttachedToRecyclerView(this);
        }
        if (mLayout != null) {
            mLayout.onAdapterChanged(oldAdapter, mAdapter);
        }
        mRecycler.onAdapterChanged(oldAdapter, mAdapter, compatibleWithPrevious);
        mState.mStructureChanged = true;
        markKnownViewsInvalid();
    }

解决方案: 在 Fragment 的 onDestroyView 方法中把 Adapter 置空或设置 RecyclerView 的 Adapter 为空:

public void onDestroyView() { 
    mRecyclerView.setAdapter(null);
    super.onDestroyView();
}
public void onDestroyView() { 
    mAdapter = null;
    super.onDestroyView();
}
23. 配置 android:sharedUserId=“android.uid.system” 的系统应用中使用 WebView 报错

问题现象: 程序崩溃,报错: java.lang.UnsupportedOperationException: For security reasons, WebView is not allowed in privileged processes

原因分析: 为了安全性考虑,不允许在享有特权的进程也就是系统进程里面使用 WebView , 首次使用时,系统会进行检查,如果 UID 是 root 进程或者系统进程,直接抛出异常。

static WebViewFactoryProvider getProvider() {
        synchronized (sProviderLock) {
            // For now the main purpose of this function (and the factory abstraction) is to keep
            // us honest and minimize usage of WebView internals when binding the proxy.
            if (sProviderInstance != null) return sProviderInstance;
 
            final int uid = android.os.Process.myUid();
            if (uid == android.os.Process.ROOT_UID || uid == android.os.Process.SYSTEM_UID) {
                throw new UnsupportedOperationException(
                        "For security reasons, WebView is not allowed in privileged processes");
            }
            ...
    }

解决方案: 我们注意到只有 sProviderInstance 为空的时候系统才去检查进程,然后创建 sProviderInstance对象。所以在使用 WebView 之前,我们先 Hook WebViewFactory,创建 sProviderInstance 对象,从而绕过系统检查。

public static void hookWebView() {
        int sdkInt = Build.VERSION.SDK_INT;
        try {
            Class<?> factoryClass = Class.forName("android.webkit.WebViewFactory");
            Field field = factoryClass.getDeclaredField("sProviderInstance");
            field.setAccessible(true);
            Object sProviderInstance = field.get(null);
            if (sProviderInstance != null) {
                log.debug("sProviderInstance isn't null");
                return;
            }
            Method getProviderClassMethod;
            if (sdkInt > 22) {
                getProviderClassMethod = factoryClass.getDeclaredMethod("getProviderClass");
            } else if (sdkInt == 22) {
                getProviderClassMethod = factoryClass.getDeclaredMethod("getFactoryClass");
            } else {
                log.info("Don't need to Hook WebView");
                return;
            }
            getProviderClassMethod.setAccessible(true);
            Class<?> providerClass = (Class<?>) getProviderClassMethod.invoke(factoryClass);
            Class<?> delegateClass = Class.forName("android.webkit.WebViewDelegate");
            Constructor<?> providerConstructor = providerClass.getConstructor(delegateClass);
            if (providerConstructor != null) {
                providerConstructor.setAccessible(true);
                Constructor<?> declaredConstructor = delegateClass.getDeclaredConstructor();
                declaredConstructor.setAccessible(true);
                sProviderInstance = providerConstructor.newInstance(declaredConstructor.newInstance());
                log.debug("sProviderInstance:{}", sProviderInstance);
                field.set("sProviderInstance", sProviderInstance);
            }
            log.debug("Hook done!");
        } catch (Throwable e) {
            log.error(e);
        }
    }

三、高版本系统适配的坑

1. Android 8.0 应用无法使用静态注册的大部分隐式广播

问题现象: 如使用静态注册方式注册 ACTION_PACKAGE_REPLACED 等隐式广播,将收不到广播消息。

原因分析: Google 官网中指出,如果应用注册为接收广播,则在每次发送广播时,应用的接收器都会消耗资源。 如果多个应用注册为接收基于系统事件的广播,则会引发问题:触发广播的系统事件会导致所有应用快速地连续消耗资源,从而降低用户体验。 为了缓解这一问题,Android 7.0(API 级别 24)对广播施加了一些限制,但是也有一些隐式广播当前已不受此限制所限,请参阅 隐式广播例外

解决方案: 通过调用 Context.registerReceiver() 动态注册方式替换清单文件中声明的静态广播。

2. Android 8.0 后台位置信息限制

问题现象: Android 8.0 后台定位每小时仅接收几次位置信息更新。

原因分析: Google 官方文档 说明:为节约电池电量、保持良好的用户体验和确保系统健康运行,在运行 Android 8.0 的设备上使用后台应用时,降低了后台应用接收位置更新的频率。

解决方案:

  • 将应用转至前台。
  • 调用 startForegroundService() 以启动应用中的某个 前台服务。此前台服务处于活动状态后,会以通知的形式持续显示在 通知区域
  • 使用 Geofencing API 的元素(例如 GeofencingClient),这些元素经过了优化,可以最大限度减少耗电量。
    使用被动位置侦听器,在前台应用以更高的频率请求位置信息更新时,这样可以提高位置信息更新的接收频率。
3. Android 8.0 启动服务的问题

问题现象: Android 8.0 使用 startService 报错:Caused by: java.lang.IllegalStateException: Not allowed to start service Intent

原因分析: 查看了 Google 官方文档 有以下两点说明:

  • 如果针对 Android 8.0 的应用尝试在不允许其创建后台服务的情况下使用 startService() 函数,则该函数将引发一个
    IllegalStateException。
  • 新的 Context.startForegroundService() 函数将启动一个前台服务。现在,即使应用在后台运行,系统也允许其调用 Context.startForegroundService()。不过,应用必须在创建服务后的五秒内调用该服务的 startForeground() 函数。

解决方案: 使用 startForegroundService 方法启动服务,并在 Service 的 onCreate 中使用 startForeground 发送显性通知。

// 启动服务
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    context.startForegroundService(new Intent(context, MyService.class));
} else {
    context.startService(new Intent(context, MyService.class));
}

// 在MyService中

public class MyService extends Service {
    
    @Override
    public void onCreate() {
        super.onCreate();
        String channelId = "channel_id";
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
            NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
            NotificationChannel channel = new NotificationChannel(channelId, getString(R.string.app_name), NotificationManager.IMPORTANCE_HIGH);
            notificationManager.createNotificationChannel(channel);
            Notification notification = new Notification.Builder(getApplicationContext(), channelId).build();
            startForeground(1, notification);
        }
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}

注意事项: 在 Android 9.0 后有服务权限请求,需要在 manifest 加权限

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
4. Android 9.0 Http 请求调用失败

问题现象: http 网络请求无法访问,抛出异常 java.net.UnknownServiceException: CLEARTEXT communication ** not permitted by network security policy

原因分析: 官方解释:Google表示,为保证用户数据和设备的安全,针对下一代 Android 系统(Android P) 的应用程序,将要求默认使用加密连接,这意味着 Android P 将禁止 App 使用所有未加密的连接,因此运行 Android P 系统的安卓设备无论是接收或者发送流量,未来都不能明码传输,需要使用下一代 (Transport Layer Security) 传输层安全协议,而 Android Nougat 和 Oreo 则不受影响。

解决方案: 有三种解决办法:
1、APP改用 https 请求
2、targetSdkVersion 降到 27 以下
3、更改网络安全配置
前面两个方法容易理解和实现,具体说说第三种方法,更改网络安全配置。
首先,在 res 文件夹下创建一个 xml 文件夹,然后创建一个 network_security_config.xml 文件,文件内容如下:

<?xml version="1.0" encoding="utf-8"?>
  <network-security-config>
             <base-config cleartextTrafficPermitted="true" />
  </network-security-config>

接着,在AndroidManifest.xml文件下的application标签增加以下属性

<application
    ...
    android:networkSecurityConfig="@xml/network_security_config"
   ... />
5. Android 10 存储空间访问限制

问题现象: Android 10 外部存储访问权限范围限定为应用文件和媒体。

原因分析: Android 10 引入了多项功能和 隐私权变更,目的是更好地保护用户的隐私权,Google 针对外部存储引入了一个新特性,它的名字叫:Scoped Storage,Google官方对它的翻译为分区存储。默认情况下,对于以 Android 10 及更高版本为目标平台的应用,其 访问权限范围限定为外部存储,即分区存储。

解决方案: 使用 Google 推荐的 MediaStore API 中提供的 getContentUri() 方法,如下代码段中所示:

// Assumes that the storage device of interest is the 2nd one
    // that your app recognizes.
    val volumeNames = MediaStore.getExternalVolumeNames(context)
    val selectedVolumeName = volumeNames[1]
    val collection = MediaStore.Audio.Media.getContentUri(selectedVolumeName)
    // ... Use a ContentResolver to add items to the returned media collection.

四、第三方开源项目的坑

1. 集成微信登录二维码加载失败

问题现象: 集成微信登录 SDK 时偶尔出现一直不能加载到正确的二维码。

原因分析: 设备时间错误,微信 SDK 会校验当前系统时间和网络时间是否一直,当时间不一致时将不返回正确的二维码。

解决方案: 修改设备时间跟随网络,并校准一致。

2. Glide 加载图片在 Activity 销毁时报错

问题现象: 在 Activity 或 Fragment 中加载图片,未加载完成时退出界面,程序报错:You cannot start a load for a destroyed activity.

原因分析: 当 Glide.with() 传的是 Activity 或者 Fragment 的 Context 时,比较容易出现这个报错。这是由于 Activity 已经 destroy 了,但是 Glide 依然在加载图片,结果就报错了。

解决方案:

  1. 在项目中使用统一的图片加载工具类,通过传入的上下文判定是否属于 Activity 并且当 Activity 销毁后,不进行图片加载,代码如下:
public void load(Context context, String url, int defResId, int errResId, ImageView imageView) {
        if (context instanceof Activity && ((Activity) context).isDestroyed()) {
            return;
        }
        RequestOptions options = RequestOptions()
                .placeholder(defResId) //占位图
                .error(errResId)       //错误图
                .diskCacheStrategy(DiskCacheStrategy.ALL);
        Glide.with(imageView).load(url)
                .apply(options)
                .into(imageView);
    }
  1. 使用 RequestManager 来替代 Glide.with , 在统一的加载方法中定义的是 RequestManager, 然后使用时传入Glide.with(context)。RequestManager可以同步 Activity/Fragment 的生命周期,当 Activity 销毁时,Glide 的加载也会停止,代码如下:
public void load(RequestManager glide, String url, int defResId, int errResId, ImageView imageView) {
        RequestOptions options = RequestOptions()
                .placeholder(defResId) //占位图
                .error(errResId)       //错误图
                .diskCacheStrategy(DiskCacheStrategy.ALL);
        glide.load(url)
                .apply(options)
                .into(imageView);
    }

使用时直接传入 Glide.with(context)
GildeUtil.load(Glide.with(context), url, R.mipmap.def_load_img,R.mipmap.error_img,imageView);

3. Glide 加载圆角图片的坑

问题现象: Glide 加载圆角图片时,使用自定义 ImageView,如 CircleImageView,且设置了占位符,如 placeholder 或 error,则第一次不显示网络图片。

原因分析: 使用 Glide 默认没有圆角转换的功能,需要使用自定义 Transformation,但无法实现 placeholder 图片的圆角转换,但是圆角图片,使用 CircleImageView,虽然 CircleImageView 继承 ImageView,不能使用 scaleType 参数,否则报 InflateException。

解决方案:

  1. 不使用占位符:
Glide.with(context)
     .load(url)
     .into(imageView);
  1. 不使用默认动画:
Glide.with(context)
     .load(url)
     .dontAnimate()//防止设置placeholder导致第一次不显示网络图片,只显示默认图片的问题
     .placeholder(R.mipmap.def_load_img)
     .into(imageView);
4. Retrofit 当使用 Gson 解析 json 数据时,当 Response 定义为泛型 T 时,无法获取到结果

问题现象: 当使用 Gson 解析 json 数据时,如果结果类型是一个泛型比如 T,此时这个 T 如果又被其他类包裹,就会抛出异常:Caused by: java.lang.ClassCastException : com.google.gson.internal.LinkedTreeMap cannot be cast to …

public static <T> ApiResponse<T> fromJson(String json){
    return new Gson().fromJson(json, new TypeToken<ApiResponse<T>>() {}.getType());
}

原因分析: Gson 中的 TypeToken 的实现逻辑是,根据 TypeToken 的派生类 , 使用 getGenericSuperclass 获取泛型信息,而泛型并没有办法被正确的传递。

解决方案: 修改 fromJson 方法传递正确的类型,代码如下:

public static <T> ApiResponse<T> fromJson(String json,Class<T> cla) {
    Type type= $Gson$Types.newParameterizedTypeWithOwner(null, ApiResponse.class, cla);
    return new Gson().fromJson(json, type);
}
5. Mqtt 不断重连的问题

问题现象: 使用 Mqtt 协议时,如果设置了失败后重连配置,客户端有时会出现不断重连不断被踢的过程。

原因分析: 客户端使用的 cliendId 出现重复或同一个设备有多个应用集成 mqtt 并且 cliendId 相同。

解决方案: 保证客户端的 cliendId 唯一,并且服务器设置禁连黑名单,当某个客户端在一定时间内出现重连次数超过设置的阈值次数(比如一分钟超过 10 次),则把客户端加入禁连黑名单,达到设置的阈值时间后再解禁(比如设置黑名单设备 1 小时后才能重连),保证出现问题后也能减少资源的消耗。

五、结语

本文总结了一些在过去开发中遇到的一些问题,希望如果大家在以后的开发中也遇到同样的问题,能提供一个参考思路和解决方案,避免踩坑。