我们一路奋斗,不是为了改变世界,而是为了不被世界改变。

ViewPager使用指南

ViewPager 是Android SDK提供的用于实现左右滑动切换页面效果的控件,接入非常简单,可以实现如下图的效果。

ViewPager
ViewPager

自顶向下地看,一个完整的包含ViewPager的页面由以下几个对象构成。

  • Host:容器页面,可以是Activity,或者Fragment
  • ViewPager:关联到页面上的一个View,可以左右滑动切换子页面
  • Adapter:ViewPager内部用以获取每个子页面的适配器,参考RecyclerView/ListView的Adapter
  • SubFragment:ViewPager内嵌的子页面

让我们逐个分析(Host就是一个普通页面,略过不提,SubFragment也一样,与常见写法没有区别,同样略过)

ViewPager

相当于一个ViewGroup容器,使用的时候,首先在xml布局里声明android.support.v4.view.ViewPager,接着在代码里通过findViewById获取到这个ViewPager,并为其设置Adapter。

1
2
3
mViewPager = findViewById(R.id.view_pager)
// 这里需要传入一个FragmentManager,可见ViewPager内部是以Fragment作为每个子页面呈现方式的
mViewPager.adapter = YourAdapterClass(supportFragmentManager)

在使用ViewPager时,往往需要对当前选中页面的行为进行监听,比如当用户左右滑动切换页面时,对应地改变标题栏的文字,对应的是addOnPageChangeListener接口,注意这里是add并非set,意味着不要对同一个对象多次调用,否则会多次触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ViewPager.java
public void addOnPageChangeListener(@NonNull OnPageChangeListener listener) {
if (mOnPageChangeListeners == null) {
mOnPageChangeListeners = new ArrayList<>();
}
mOnPageChangeListeners.add(listener);
}

public interface OnPageChangeListener {
// 页面发生位移时调用,既包含用户手指拖动,也包含页面自身的动画移动。参数是位移的百分比和像素值,可以用来进行一些计算
void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);

// 页面被选中时调用,动画也许并没有结束,参数是被选中页面的index
void onPageSelected(int position);

// 页面状态发生变化时调用,不特指哪一个页面,而是所有页面。
// 有三种状态:IDLE(页面静止,无动作)、DRAGGING(用户拖动中)、SETTING(用户已放手,页面归位中)
void onPageScrollStateChanged(int state);
}

接口还是比较简单的,同时,如果我们只关注三个回调中的一个(往往是onPageSelected),可以用另一个内部类来创建监听对象,以减少样板代码,SimpleOnPageChangeListener同样位于ViewPager.java中。

这是很好的一种编程思想,对于包含多个回调函数的监听接口,增加一个内部类,为每个回调函数创建一个空函数,在使用时只覆写业务需要的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static class SimpleOnPageChangeListener implements OnPageChangeListener {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
// This space for rent
}

@Override
public void onPageSelected(int position) {
// This space for rent
}

@Override
public void onPageScrollStateChanged(int state) {
// This space for rent
}
}

Hint:如果要在onPageSelected回调里获取相应的SubFragment,不要使用Adapter.getItem,它会返回一个新创建的Fragment。应当调用的方法是Adapter.instantiateItem,这会返回已创建的Fragment,参考Stack Overflow上面的这个问题

Adapter

有两种Adapter,FragmentPagerAdapter和FragmentStatePagerAdapter,简单地说,如果你的ViewPager只包含3到4个固定的页面,则使用FragmentPagerAdapter;如果有很多个页面,则使用FragmentStatePagerAdapter。

这里以FragmentStatePagerAdapter为例,介绍Adapter的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// FragmentStatePagerAdapter.java
public abstract class FragmentStatePagerAdapter extends PagerAdapter {
// ...some code...
// 这里创建Fragment并返回
public abstract Fragment getItem(int position);
// ...some code...
}

// PagerAdapter.java
public abstract class PagerAdapter {
// ...some code...
// 返回Fragment总个数
public abstract int getCount();
// ...some code...
}

可见最简单的FragmentStatePagerAdapter只需要实现getItemgetCount两个方法。值得一提的是,如果需要在创建SubFragment时传递一些参数,用以下写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 创建Fragment时传入Arg_0
override fun getItem(position: Int): Fragment {
val frag = YourFragmentClass()
frag.arguments = Bundle().apply {
putString(ARG_0, some_value)
}
// ... some code ...
}

// 在Fragment的onViewCreated里读取参数
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
arguments?.takeIf { it.containsKey(ARG_0) }?.apply {
val someValue = getString(ARG_0)
// ... some code ...
}
}

- Activity 传递大数据

在使用Intent进行Activity之间的跳转时,系统提供了putExtra用于参数传递,如下例。

1
2
3
4
5
6
// caller activity
val intent = Intent(this, TheNextActivity::class.java)
i.putExtra(ARG_0, 100)

// called activity
val param = intent.extras?.getInt(ARG_0)

如果传递的参数不是基础类型,而是列表,则使用putExtra(String, Parcelable)getParcelableExtra(String)做相应的存取。

然而,实际上很多人并不知道,通过Intent传递的参数,是有大小限制的。当我们传递占内存非常大的数据,如1000个元素的列表、Bitmap等等时,稍不注意,就会出现TransactionTooLargeException,从异常名就可以看出,这是由于参数过大引起的。究其原因,是因为ActivityManagerService内部使用了Binder通信机制,其事务缓冲区限制了传输数据的大小。Binder事务缓冲区的大小为1MB,而且,这1MB还不是独享的,意味着有时尽管传递的数据没有超出1MB,也会触发异常。

那么,对于需要传递大量数据的场景,有哪些方案?

单例

1
2
3
4
object Singleton {
var items: List<Foo>? = null
var largeImg: Bitmap? = null
}

不需要过多解释,注意不要出现内存泄漏,以及单例无法在进程之间共享。

持久化

利用网络、数据库、文件、SharedPreference等方式,将数据持久化保存,随后在新页面读取。优点是保存后可以跨进程甚至跨应用、跨平台使用,缺点则是效率低下,读写时没有控制好事务会发生异常。

使用EventBus

在《阿里巴巴Android开发手册》中写到:“Activity 间的数据通信,对于数据量比较大的,避免使用 Intent + Parcelable 的方式,可以考虑 EventBus 等替代方案,以免造成 TransactionTooLargeException。”

由于EventBus滥用会导致代码结构混乱,因此个人不推荐。

参考资料https://juejin.im/post/5d8de547e51d45781f73bacc

- Kotlin单例写法

单例模式是日常开发中最常使用到的设计模式,一个良好的单例模式实现应当兼顾代码性能与调用简便两个方面。在Java中我们通过“双锁”或者“静态内部类”来实现单例模式,相比之下我更喜欢静态内部类的写法,《Effective Java》一书的作者也是这样认为的。

1
2
3
4
5
6
7
8
9
10
11
12
// 样例代码,来自 wiki:https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom
public class Something {
private Something() {}

private static class LazyHolder {
static final Something INSTANCE = new Something();
}

public static Something getInstance() {
return LazyHolder.INSTANCE;
}
}

无参数写法

今天主要讨论Kotlin的单例写法,在Kotlin中,单例被上升到了语言层面,关键字object可以用来声明一个不需要参数的单例对象。

1
2
3
4
5
object SomeSingleton {
init {
// 在这里添加初始化代码
}
}

借助于JVM加载类的过程,它编译后的等效Java代码也是线程安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 上述Kotlin代码的Java等价版本
public final class SomeSingleton {
public static final SomeSingleton INSTANCE;

private SomeSingleton() {
INSTANCE = (SomeSingleton)this;
System.out.println("init complete");
}

static {
new SomeSingleton();
}
}

有参数写法

有时我们需要在单例初始化时传入一些参数,比如Glide.with(Context),此时object关键字就捉襟见肘了。在Stack Overflow这个问题下面可以学习到,借助伴生对象的“伪静态方法”,能达到传入初始化参数的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UsersDatabase : RoomDatabase() {

companion object {

@Volatile private var INSTANCE: UsersDatabase? = null

fun getInstance(context: Context): UsersDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
}

private fun buildDatabase(context: Context) =
Room.databaseBuilder(context.applicationContext,
UsersDatabase::class.java, "Sample.db")
.build()
}
}

以上这种写法,需要关注以下几点。

  1. 单例成员INSTANCE需要有@Volatile声明,以保证对象唯一
  2. synchronized加锁防止重复初始化
  3. 借助also返回原对象

如果代码里只有一个单例类要实现,上面这种写法就足够了。但是,若有很多个单例类,这种写法产生的样板代码可不少。是不是可以把样板代码逻辑抽出,一次书写,多处调用?答案是肯定的。

有参数写法,Write Once,Use Many

首先区分上述实现方式里,可变的部分与不变的部分,思路是把不变的部分抽象成流程,把可变的部分提取作为参数。

不变的部分是检查、维护、调用构建函数,将其抽出一个类,这个类一定是用于被继承,因此我们将其声明为open,通过lambda表达式参数constructor,开放出构建对象的能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SingletonHolder.kt
open class SingletonHolder<out T, in A>(private val constructor: (A) -> T) {

@Volatile
private var instance: T? = null

fun getInstance(arg: A): T {
return when {
instance != null -> instance!!
else -> synchronized(this) {
if (instance == null) instance = constructor(arg)
instance!!
}
}
}
}

此时,有一个类需要增加单例实现,并且其构造函数需要一个Context类型的参数,我们只需要在其内部声明一个伴生对象,继承自SingletonHolder<MyManager, Context>

1
2
3
4
5
6
7
8
9
// MyManager.kt
class MyManager private constructor(context: Context) {

fun doSomething() {
// ...
}

companion object : SingletonHolder<MyManager, Context>(::MyManager)
}

对单例的调用者而言,写法与Java无异。

1
MyManager.getInstance(context).doSomething()

怎么样,是不是与Glide的Glide.with(context).load(img_url)完全一致?Bravo!

参考资料

Vimium的页面检索技巧

在使用浏览器时,有时我们会打开很多个页面,此时如果想要在打开的页面里找到特定页面,往往需要从头翻到尾,十分之麻烦。Vimium考虑到了这一点,并为我们提供快捷键T解决。这个功能属于Vomnibar功能集,是Vimium提供的一组页面新建、搜索快捷键,一共有5个。

  • o,在当前Tab打开URL、书签或浏览历史
  • O,新建Tab打开URL、书签或浏览历史
  • b,在当前Tab打开书签
  • B,新建Tab打开书签
  • T,也就是刚刚介绍过的,在已打开的Tab中进行搜索

(顺带提一下,Sublime的copy line快捷键是Ctrl+Shift+D,在写着一段时用到的。)

使用默认参数简化自定义View的构造函数

在编写自定义View的类时,如果自定义View继承自android.view.View,通常需要覆写多个构造函数,以支持View的多种构建方式。这种处理不仅麻烦,还带来大量样板代码,稀释了我们的代码质量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// View.java
public View(Context context) {
// ...
}

public View(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}

public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
// ...
}

联想到Kotlin函数的默认参数功能,是不是可以将其应用在这种场景中呢?答案当然是可以。结合@JvmOverloads注解和默认参数,写法如下。

1
2
3
4
5
6
7
8
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributSet? = null,
defStyle: Int = 0,
defStyleRes: Int = 0
) : View(context, attrs, defStyle, defStyleRes) {
// class body
}

在此基础上,借助init{ ... }代码块,可以执行自定义的初始化代码。

参考https://stackoverflow.com/questions/20670828/how-to-create-constructor-of-custom-view-with-kotlin

MediaPlayer状态机

MediaPlayer是Android SDK提供的音视频播放组件,尽管目前有更优秀的IJKPlayer、EXOPlayer等开源项目,MediaPlayer作为功能单一、接口清晰的播放器,有其值得学习的意义。一切故事,从一张状态机图片开始。

状态机
状态机

图例说明单箭头表示同步调用,双箭头表示异步调用,双层椭圆(仅End)表示终结态。

这张图乍一看像是一团乱麻,其实可以按照播放前、播放中、播放后的阶段进行区分。

播放前

  • Prepared为界,之前的状态都可以认为是“播放前”
  • 通过new创建一个播放器,或者对已有播放器调用reset,均可以得到一个处于Idle状态的播放器。不过这两种方式有一个显著区别,即对Idle态播放器调用getCurrentPosition(), getDuration(), getVideoHeight(), getVideoWidth(), setAudioAttributes(), setLooping(), setVolume(), pause(), start(), stop(), seekTo(), prepare(), prepareAsync()方法时,如果是新构建的播放器,不会抛出任何一场,而如果是通过reset得到的Idle播放器,则会进入OnErrorListener.onError()回调
  • 播放器在开始播放前,必须进入Prepared态。有两种方法,分别是同步的prepare()和异步的prepareAsync()。同步方法的返回是很快的,几乎是瞬间。对于异步调用,可以通过setOnPreparedListener()设置监听
  • 当播放器处于Prepared态时,可以设置音量、屏幕常亮、循环播放等属性

播放中

  • 播放过程可能因为各种原因发生异常,诸如不支持的音视频格式、受损的文件、分辨率过高、解码超市等等原因,或者是对于播放器调用了不属于其状态的方法。在这些错误发生时,会走到OnErrorListener.onError()回调中,因此在播放前设置监听setOnErrorListener()是非常重要的
  • 设置onError监听并不能避免播放器进入Error态,只是在进入时发出程序可以观测到的监听事件
  • 如果在错误的状态调用prepare(), prepareAsync(), setDatasource(),会导致IllegalStateException
  • 基于上一条,在调用setDatasource以及它的众多重载方法时,必须捕获IllegalArgumentExceptionIOException
  • 通过start()启动播放,通过isPlaying()判断当前是否处于播放中,可以在start()后继续调用start(),但这不会产生任何影响
  • 在开始播放后,可以通过setOnBufferingUpdateListener()监听视频缓冲进度
  • 对于播放中的视频,调用pause()进入Paused态,这是一个略微有延迟(seconds)的调用,意味着isPlaying()可能不会立即反映当前状态,反之亦然
  • 对于Started, Paused, Prepared, PlaybackCompleted态的播放器调用stop(),使其进入Stopped态;对于Stopped态的播放器,必须使其再次进入Prepared态后,方可用于播放
  • start()一样,多次调用stop()不会产生任何影响
  • seekTo()设置播放进度,这是一个异步方法,OnSeekComplete.onSeekComplete()用于监听;可以在Prepared, Paused, PlaybackCompleted多个态调用,且调用seekTo()后播放器仍保持原状态,同时改变当前帧;相应的,getCurrentPosition()可以返回当前的播放进度

播放后

  • 一旦播放器不再使用,建议立即调用release()释放资源,此后播放器进入End态,且再也无法通过任何方法使其恢复
  • 如果设置了Looping,播放完成后会保持Started态,否则会进入OnCompletionListener回调,并进入PlaybackCompleted

播放器的权限要求

视需求而定,可能需要WAKE_LOCK以及Internet权限。

线程限制

必须在UI线程创建播放器,只有这样才能正常收到为播放器设置的各种回调。

参考https://developer.android.com/reference/android/media/MediaPlayer