Android通讯录简单开发及优化

展文悦

记录一下自己对手机通讯录功能的优化过程,集中解决大数据的显示效率、索引定位、常见UI实现。有一些小心思在里面,所以记录一下,以后忘了可以快速回忆。demo放在最后

起因

原先获取通讯录数据是直接跳到手机系统的通讯录,但是存在局限性只能选择单条数据。参考网上大多使用ContentProvider获取后采用遍历的方式加载数据,遇到上万条数据会有明显卡顿,忍不了,只能自己动手。

列举一个完整的仿手机通讯录需要的功能:

image.png

效果

21-11-16-16-13-22.gif

开始

往手机通讯录中添加1w+条数据

获取通讯录数据

不直接使用ContentProvider + 遍历的形式(遍历一次上万条数据耗时虽然只有几秒,但是在UI上展示会有明显卡顿)。

采用CursorLoader异步获取所需cursor。然后直接加载cursor对象到recyclerView的adapter中,通过移动cursor的指针来控制所显示的数据,利用cursor指针这样只会加载手机屏幕所显示Item条数,速度会有明显的提升。

Loader是Android3.0的api,用来进行异步任务,google官方已经为我们封装好了可进行异步操作获取本地数据库指针的CursorLoader,结合LoaderManager管理和使用。

//this - 所挂钩的生命周期的context
//id - loaderManager的唯一标识
//args - 构造时提供给loader的可选参数,没有可null
//callback - 回调接口
LoaderManager.getInstance(this).initLoader(id,args, callback)
复制代码
//回调
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
    //通讯录表的URI
    val uri = ContactsContract.Contacts.CONTENT_URI
    //参数依次为:上下文-所查询列名-查询条件语句-条件对应的参数-排序
    return CursorLoader(this, uri, null, null, null,ContactsContract.Contacts.SORT_KEY_PRIMARY)
}

override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
    if (data == null || data.count == 0) {
        return
    }
    mContactAdapter?.setData(data)
    mRvAlphabet.setCursor(data)
}

override fun onLoaderReset(loader: Loader<Cursor>) {
}
复制代码

在onCreateLoader中初始化查询表构造函;在onLoadFinished回调,将所查结果传递给列表适配器并显示。

注意 : 联系人表(view_contacts)和电话存放的数据表(view_data)为两张表,即无法单从联系人表获取电话信息,所以需要根据联系人表_id查询对应电话。考虑到可能存在一个联系人有多个电话的情况,且如果在主线程查询会导致滑动卡顿,所以优化点是在异步线程查询电话(代码使用RxJava)。

//部分代码
val id = it.getString(it.getColumnIndex(ContactsContract.Contacts._ID))
val hasPhone = it.getString(it.getColumnIndex(ContactsContract.Contacts.HAS_PHONE_NUMBER))
if (hasPhone == "1") {
  //异步查询电话
  queryPhoneFromId(itemView.context, id) { p ->
    itemView.mTvPhone.text = p
  }
} else {
  itemView.mTvPhone.text = ""
}
复制代码
/**
 * 根据联系人表id查询电话号
 */
private fun queryPhoneFromId(context: Context, id: String, callBack: ((phone: String) -> Unit)? = null) {
  Observable.just(id)
    .subscribeOn(Schedulers.io())
    .observeOn(Schedulers.io())
    .map {
      val phone = StringBuilder()
      val phoneCursor: Cursor? = context.contentResolver.query(
        ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
        null,
        ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = " + it, null, null
      )
      if (phoneCursor != null) {
        while (phoneCursor.moveToNext()) {
          val phoneNumber = phoneCursor.getString(phoneCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER))
          if (!TextUtils.isEmpty(phoneNumber)) {
            phone.append("$phoneNumber,")
          }
        }
        phoneCursor.close()
      }
      return@map phone
    }
    .observeOn(AndroidSchedulers.mainThread())
    .doOnNext {
      if (it.isNotEmpty()) {
        callBack?.invoke(it.substring(0, it.length - 1).replace(" ", ""))
      } else {
        callBack?.invoke("")
      }
    }
    .doOnError {
      it.printStackTrace()
    }
    .subscribe()
}
复制代码

吸顶效果

因为列表使用的是recyclerView,所以通过重写recyclerView的ItemDecoration实现,代码较多,具体请见demo

侧边栏索引

想要侧边栏首字母快速定位到所有联系人数据中每个字母第一次出现的位置,如果获取全部数据后使用遍历,时间复杂度是O(n),虽然可以实现功能,但是我想让查询更快一些。我尝试采用二分法O(logN)后发现可以缩短一部分时间,同时翻阅文档也发现一个好东西:

这里引入一个字母索引类:AlphabetIndexer

官方描述:
实现SectionIndexer接口的适配器的辅助类。 如果适配器中的项目通过简单的基于字母排序进行排序,则此类提供了
使用二分查找快速索引大型列表的方法。 它缓存通过二进制搜索确定的索引,并且如果光标发生更改,也会使缓存无
效。
Your adapter is responsible for updating the cursor by calling `setCursor(Cursor)` if the
cursor changes. `getPositionForSection(int)` method does the binary search for the starting 
index of a given section (alphabet).
复制代码

通过传入的cursor#getString()方法获取索引创建排序映射,内部也使用了二分法。(人懒想偷偷省点力,遂就使用了)

注意:索引仅支持首位空格及A-Z,即 ABCDEFGHIJKLMNOPQRSTUVWXYZ,但是我们使用原生手机通讯录末尾包含特殊符号#(非首字母的联系人,比如数字等),所以还需自己进行特殊处理,查找到Z-#中#首次出现的位置。我沿写了折半查找位于Z-#中#首次出现的位置(其实这里直接遍历也可以,因为#的数据会比较少)

//完整的自定义侧边索引,包括手势滑动
class LetterIndexRecyclerView @JvmOverloads constructor(
  context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {

  val alphabetArray: Array<String> = arrayOf(
    "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
    "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "#"
  )

  /**
   * 每个字母所占高度
   */
  private var itemHeight = DisplayUtil.dp2px(17f)

  /**
   * 字母表索引器
   */
  private var alphabetIndexer: AlphabetIndexer? = null

  var onMoveClickListener: ((position: Int, letter: String) -> Unit)? = null

  /**
   * 记录上一次手势字母表索引
   */
  private var lastAlphabetIndex = -1

  private var mCursor: Cursor? = null

  override fun onTouchEvent(e: MotionEvent?): Boolean {
    when (e?.action) {
      MotionEvent.ACTION_DOWN -> {
        val getY = e.y
        val alphabetIndex = getNowAlphabetIndex(getY)
        if (lastAlphabetIndex != alphabetIndex) {
          lastAlphabetIndex = alphabetIndex
          val position = calculateAlphabetPosition(alphabetIndex)
          onMoveClickListener?.invoke(position, alphabetArray[alphabetIndex])
        }
      }
      MotionEvent.ACTION_MOVE -> {
        val getY = e.y
        val alphabetIndex = getNowAlphabetIndex(getY)
        if (lastAlphabetIndex != alphabetIndex) {
          lastAlphabetIndex = alphabetIndex
          val position = calculateAlphabetPosition(alphabetIndex)
          onMoveClickListener?.invoke(position, alphabetArray[alphabetIndex])
        }
      }
      MotionEvent.ACTION_UP -> {
        lastAlphabetIndex = -1
        onMoveClickListener?.invoke(-1, "")
      }
    }
    return super.onTouchEvent(e)
  }

  fun setCursor(cursor: Cursor) {
    this.mCursor = cursor
    alphabetIndexer = AlphabetIndexer(
      IndexCursor(cursor),
      cursor.getColumnIndex(ContactsContract.Contacts.SORT_KEY_PRIMARY),
      "ABCDEFGHIJKLMNOPQRSTUVWXYZZ"
    )
  }

  /**
   * 返回当前手势所在字母表索引
   */
  private fun getNowAlphabetIndex(rawY: Float): Int {
    //倍数
    val multiple: Int = (rawY / itemHeight).toInt()
    return if (multiple <= 0) 0 else if (multiple >= alphabetArray.size - 1) alphabetArray.size - 1 else multiple
  }

  /**
   * 计算字母表位置对应首条数据位置
   */
  private fun calculateAlphabetPosition(alphabetIndex: Int): Int {
    val position = alphabetIndexer?.getPositionForSection(alphabetIndex) ?: -1
    if (position == -1) return -1
    val letter = alphabetArray[alphabetIndex]
    println("===检查合理性===$position $letter")
    if (letter != "#") {
      if (position == mCursor?.count) return -1
      val sectionForPosition = alphabetIndexer?.getSectionForPosition(position) ?: -1
      if (sectionForPosition != -1) {
        val sectionLetter = alphabetArray[sectionForPosition]
        if (sectionLetter == letter) return position
      }
      return -1
    } else {
      //判断position是否返回count,如果返回说明没有#
      val all = mCursor?.count ?: position
      if (position >= all) {
        //不存在#
        return -1
      }

      //position不是count,判断最后一条是否是Z,是的话说明没有#
      val endLetter = getFirstLetter(all - 1)
      if (endLetter.matches(Regex("[A-Z]"))) {
        return -1
      }

      //二分法查找
      return search(position, all)
    }
  }

  /**
   * 查找#号首次出现的索引
   */
  private fun search(left: Int, right: Int): Int {
    if (left == right) {
      return if (!getFirstLetter(left).matches(Regex("[A-Z]"))) left else -1
    }

    val mid = (right + left) / 2
    return if (!getFirstLetter(mid).matches(Regex("[A-Z]"))) {
      search(left, mid)
    } else {
      search(mid + 1, right)
    }
  }

  /**
   * 获取首字母
   */
  private fun getFirstLetter(position: Int): String {
    mCursor?.let {
      it.moveToPosition(position)
      val name = it.getString(it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME))
      return PinUtil.converterToFirstSpell(name).substring(0, 1).toUpperCase(Locale.CHINA)
    }
    return ""
  }
}
复制代码

搜索

参考手机上的通讯录搜索结果是姓名、电话合并一条数据展示的,但是我qury到现在姓名和电话是两个表(电话表是有姓名的,但是是相同的人有多个电话会将电话拆分创建多条数据,不满足我想直接通过一个姓名查到结果。哎没法偷懒。其中我也尝试过在selection拼接语句进行联合查询,但是并不理想)。最后选择分页+集合的方式,从侧面提升搜索结果数据显示的流畅度。

在editText监听到数据改变后,查询表view_data

mEtSearch.addTextChangedListener(object : TextWatcher {
  override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
  }

  override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
  }

  override fun afterTextChanged(s: Editable?) {
    keyword = s.toString()

    if (TextUtils.isEmpty(keyword)) return
    //搜索电话表
    phoneSelection = "${ContactsContract.CommonDataKinds.Phone.NUMBER} like '%$keyword%' or ${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} like '%$keyword%'"
    LoaderManager.getInstance(this@SearchContactActivity).restartLoader(1, null, this@SearchContactActivity)
  }
})
复制代码

因为我模拟的数据是上万条的,如果一次模糊查询完全部数据展示,快速输入时(我没做editText的延迟加载)会导致UI卡顿明显,考虑之后决定使用分页加载。通过在cursorLoader中添加查询条件获得匹配的cursor后,每次只加载50条数据。

//首次获取到loader结果后只加载50条数据
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
  println("===onLoadFinished1===${loader.id} ${data?.count}")
  if (loader.id == 1 && keyword != lastKeyword) {
    lastKeyword = keyword
    searchList.clear()
    mCursor = data
    mCursor?.let {
      while (it.moveToNext() && it.position <= 50) {
        val id = it.getString(it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID))
        val name = it.getString(it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME))
        val bean = SelectBean(id, name)
        if (!searchList.contains(bean)) {
          searchList.add(bean)
        }
      }
      mAdapter?.setList(searchList)
    }
  }
}
复制代码
//加载更多回调,指针移动到至多下一个50条
.setOnLoadMoreListener {
  mCursor?.let {
    it.moveToPrevious()
    val lastPosition = it.position
    while (it.moveToNext() && it.position <= lastPosition + 50) {
      val id =
        it.getString(it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID))
      val name =
        it.getString(it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME))
      val bean = SelectBean(id, name)
      if (!searchList.contains(bean)) {
        searchList.add(bean)
      }
    }
  }
复制代码

后记

通过上述,一个自定义通讯录的基本功能就完成了,虽然与网上大多数通讯录写法并无太大区别,但是一些小优化带来的性能提升及丝滑程度有明显的改善,一些小细节的改变希望对自己的以后的思维方式带来更大的发散作用吧。

此致记录终

最后附上Demo地址

github.com/wqQiu/Andro…

おすすめ

転載: juejin.im/post/7031365849294307359