Android问题篇之UI问题(二)

242 阅读6分钟

UI问题汇总

11. ScrollView嵌套LinearLayout 水平滑动设置不生效

  • 在 Android 中,若要实现水平滑动效果,直接使用 ScrollView 是不行的,因为 ScrollView 仅支持垂直滚动。你需要使用 HorizontalScrollView 来实现水平滑动功能。
  • 若直接把 LinearLayout 的 orientation 设为 horizontal 然后嵌套在 ScrollView 里,是无法实现水平滑动的。
  • 正确示例
<HorizontalScrollView
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:padding="8dp">
        ...
    </LinearLayout>
</HorizontalScrollView>

// 或
<androidx.constraintlayout.widget.ConstraintLayout ...>
    <HorizontalScrollView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:scrollbars="none"
        app:layout_constraintStart_toEndOf="@id/xxx_left"
        app:layout_constraintEnd_toStartOf="@id/xxx_right"
        app:layout_constraintBottom_toBottomOf="parent">

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content">
            ...
        </LinearLayout>
    </HorizontalScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

10. 使用EditText#setFilters(filter)设置过滤规则时,某些键盘自带联想词功能会将前序输入字母拼接联想,处理不当会导致输入框输入字符逐渐累加

例:输入字母a,接着输入b,这时输入框内容会变为aab,接着输入c,输入框内容变成aababc

  • 修复方案:可通过InputConnection实现过滤字符效果
class LimitEditText(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0):
    AppCompatEditText(context, attrs, defStyleAttr) {
    constructor(context: Context): this(context, null)
    constructor(context: Context, attrs: AttributeSet? = null): this(context, attrs, 0)

    override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
        return super.onCreateInputConnection(outAttrs)?.let { LimitInputConnection(it, false) }
    }

    inner class LimitInputConnection(target: InputConnection, mutable: Boolean):
        InputConnectionWrapper(target, mutable), InputConnection {
        override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
            /**
             * 限制字符
             */
            val result = text?.toString()?:""
            val isChinese = Pattern.matches("[\u4e00-\u9fa5]", result)
            if (!Character.isLetterOrDigit(result.toCharArray()[0]) || isChinese || result.contains("\n") || result.contains("\r")) {
                return false
            }
            return super<InputConnectionWrapper>.commitText(text, newCursorPosition)
        }
    }
}

9. 在Adapter中使用Glide加载图片上下文为ImageView时,当有其他View层级在该View之上显示时,显示异常问题

  • 原因Glide绑定View的生命周期,导致图片回收无法显示
  • 修改其生命周期绑定

8. ViewPager2嵌套RecyclerView,ItemView拖拽事件冲突问题

  • 解决思路
      1. RecyclerView的滑动冲突,可通过重写LayouManager相关方法解决
      1. ViewPager2的滑动冲突,可通过禁用ViewPager2的滑动事件解决
// ViewPager2中
override fun enablePager2(enable: Boolean) {
    // true滑动,false禁止滑动
    binding.pager2.isUserInputEnabled = enable
}

//RecyclerView中
class TabPagerImage(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
    constructor(context: Context): this(context, null)
    constructor(context: Context, attrs: AttributeSet? = null): this(context, attrs, 0)

    private val tabImageAdapter: TabImageAdapter
    var listener: OnTabImageListener? = null
    private val binding by lazy {
        TabPagerImageBinding.inflate(LayoutInflater.from(context), this, true)
    }
    
    init {
        tabImageAdapter = TabImageAdapter(mutableListOf(), { dx, dy ->
        }, { preview ->
            // 回调中禁用启用ViewPager2的滑动事件
            if (preview == null) {
                listener?.enablePager2(true)
                ...
            } else {
                listener?.enablePager2(false)
                ...
            }
        })
        
        // RecyclerView以GridLayoutManager为例
        binding.tabImageRv.layoutManager = object : GridLayoutManager(context, 3) {
            override fun canScrollVertically(): Boolean {
                return !tabImageAdapter.disableDragging && super.canScrollVertically()
            }
        }
    }
}

// Adapter中
@SuppressLint("ClickableViewAccessibility")
class TabImageAdapter(
    private val datasource: MutableList<TabContent>,
    val movePreview: (Float, Float) -> Unit,
    val preview: (ImageView?) -> Unit,
): Adapter<TabImageAdapter.Holder>() {
    var disableDragging: Boolean = false

    inner class Holder(val binding: ItemTabImageBinding): ViewHolder(binding.root) {
        init {
            binding.itemTabImage.setOnTouchListener { v, event ->
                val index = event.actionIndex
                val pointerId = event.getPointerId(index)
                when (event.actionMasked) {
                    MotionEvent.ACTION_DOWN,
                    MotionEvent.ACTION_POINTER_DOWN -> {
                        if (pointerId != 0) {
                            return@setOnTouchListener false
                        }
                        disableDragging = false
                        preview(v as ImageView)
                        dx = event.x
                        dy = event.y
                    }
                    MotionEvent.ACTION_MOVE -> {
                        disableDragging = true
                        movePreview(itemView.x + event.x - dx, itemView.y + event.y - dy)
                    }
                    MotionEvent.ACTION_UP,
                    MotionEvent.ACTION_POINTER_UP,
                    MotionEvent.ACTION_CANCEL -> {
                        if (pointerId != 0) {
                            return@setOnTouchListener false
                        }
                        preview(null)
                    }
                }
                true
            }
        }
    }
}

7. ViewPager2设置Adapter时报错

  • java.lang.IllegalStateException: Pages must fill the whole ViewPager2 (use match_parent)
  • 原因
    • 通过源码分析设置item时,要求宽高为MATCH_PARENT
/**
 * A lot of places in code rely on an assumption that the page fills the whole ViewPager2.
 *
 * TODO(b/70666617) Allow page width different than width/height 100%/100%
 */
private RecyclerView.OnChildAttachStateChangeListener enforceChildFillListener() {
    return new RecyclerView.OnChildAttachStateChangeListener() {
        @Override
        public void onChildViewAttachedToWindow(@NonNull View view) {
            RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) view.getLayoutParams();
            if (layoutParams.width != LayoutParams.MATCH_PARENT || layoutParams.height != LayoutParams.MATCH_PARENT) {
                throw new IllegalStateException("Pages must fill the whole ViewPager2 (use match_parent)");
            }
        }

        @Override
        public void onChildViewDetachedFromWindow(@NonNull View view) {
            // nothing
        }
    };
}
  • 修复
    • 在Adapter的onCreateViewHolder中设置layoutParams
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabContentAdapter.Holder {
    val page = pages[viewType]
    val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
    page.layoutParams = lp
    return Holder(page)
}

6. 自定义View中注册监听问题(例EventBus注册)

  • 需在 onAttachedToWindow 注册, onDetachedFromWindow 取消注册
  • 若在构造方法中注册, onDetachedFromWindow 取消注册,则可能会引入OOM问题(内存泄露)

原因

  1. onDetachedFromWindow 有场景会不执行,则无法取消注册导致OOM
  2. onDetachedFromWindowView 中为空实现,是通过 View#dispatchDetachedFromWindow 调用,而其又是通过 ViewRootImpl#dispatchDetachedFromWindow 调用
# ViewRootImpl#dispatchDetachedFromWindow
fun dispatchDetachedFromWindow() {
    ...
    // 当 mView 和 mView.mAttachInfo 不为 null 时,dispatchDetachedFromWindow 才会被调用  
    if (mView != null && mView.mAttachInfo != null) {  
        mAttachInfo.mTreeObserver.dispatchOnWindowAttachedChange(false);

        // dispatchDetachedFromWindow 方法在这里被调用  
        mView.dispatchDetachedFromWindow();  
    }
    ...
}
  1. mView 创建时机是在 ViewRootImpl#setView
# ViewRootImpl#setView
fun setView(view: View, attrs: WindowManager.LayoutParams, panelParentView: View, userId: Int) {  
    synchronized (this) {  
        if (mView == null) {  
            // 在 ViewRootImpl 的 setView 中会给 mView 赋值  
            mView = view;  
            ...  
    }  
}
  1. setView 调用时机是在 Activity#onResume 之后,通过 PhoneWindow 拿到 WindowManagerImpl, WindowManagerImpl#addView -> WindowManagerGlobal#addView -> ViewRootImpl#setView
  2. 若在 Activity#onResume 之前 Activity#finish 执行,则 View#onDetachedFromWindow 不会被执行,也就不会取消注册,即导致OOM
  3. 解决方案即 onAttachedToWindow 注册, onDetachedFromWindow 取消注册,成对出现,要么都执行,要么都不执行

5. EventBus引起的UI更新问题

  • 在子线程中使用EventBus发送事件,订阅事件处上报UI更新问题
  • 解决方案:订阅事件处指定线程模式

线程模式

  1. ThreadMode.POSTING:在发送事件线程调用接收方法(默认)
  2. ThreadMode.MAIN:在主线程调用接收方法
  3. ThreadMode.BACKGROUND:在后台线程调用接收方法
  4. ThreadMode.ASYNC:在异步线程调用接收方法
@Subscribe(threadMode = ThreadMode.MAIN)
fun handleEvent(event: XxxEvent) {
    ...
}

4. 禁用软键盘

class XxxActivity extends AppCompatActivity {
    @Override  
    protected void onCreate(Bundle savedInstanceState) {
        setContentView(R.layout.activity_xxx);
    
        // To disable popping-up soft-keyboard  
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM, WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
    }
}

3. ViewBinding问题

  • 布局中使用include
  • SubLayout必须与include跟布局一致,否则报错,必须包裹一层,否则设置显示隐藏会导致所有控件不可见
<!-- 在activity_main.xml中引入include_tool(include中有merge标签) -->
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <SubLayout>
        <include layout="@layout/include_tool"/>
    </SubLayout>
</LinearLayout>

<!-- 在MainActivity中使用 -->
// 方式一
val mBinding = ActivityMainBinding.inflate(layoutInflater)
val mToolBinding = IncludeToolBinding.bind(mBinding.root)
mToolBinding.toolPen.setOnClickListener {}

// 方式二
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <include android:id="@+id/include_tool"
        layout="@layout/include_tool"/>
</LinearLayout>
val mBinding = ActivityMainBinding.inflate(layoutInflater)
mBinding.includeTool.toolPen.setOnClickListener {}

2. ViewBinding ViewStub问题

  • ViewStub必须有一层父布局,否则设置显示隐藏有问题(会导致所有控件不可见)
1. 引入
android {  
    ...  
    viewBinding {  
        enabled = true  
    }  
}

2. view_stub_layout.xml  
<?xml version="1.0" encoding="utf-8"?>  
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"  
    android:layout_height="wrap_content">  
        
    <Button
        android:id="@+id/view_stub_load"  
        android:layout_width="wrap_content"  
        android:layout_height="wrap_content"  
        android:layout="@layout/view_stub_test"/>

    <FrameLayout
        android:layout_width="wrap_content"  
        android:layout_height="wrap_content">
        <ViewStub  
            android:id="@+id/view_stub_test"  
            android:layout_width="wrap_content"  
            android:layout_height="wrap_content"  
            android:layout="@layout/view_stub_test"/>
    </FrameLayout>
</FrameLayout>  

3. view_stub_test.xml  
<?xml version="1.0" encoding="utf-8"?>  
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"  
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="wrap_content"  
    android:layout_height="48dp"  
    android:gravity="center_vertical"  
    android:paddingStart="10dp"  
    android:paddingEnd="10dp"  
    android:paddingTop="8dp"  
    android:paddingBottom="8dp"  
    android:background="@drawable/vector_xxx_bg">  

    <ImageView  
        android:id="@+id/xxx"  
        android:layout_width="32dp"  
        android:layout_height="32dp"  
        android:padding="4dp"  
        android:src="@drawable/vector_xxx"  
        app:layout_constraintStart_toStartOf="parent"  
        app:layout_constraintTop_toTopOf="parent"/>  
</LinearLayout> 

4. ViewStubLayout.kt
class ViewStubLayout(context: Context, attrs: AttributeSet? = null,
    defAttr: Int = 0, defRes: Int = 0): FrameLayout(context, attrs, defAttr, defRes) {
    constructor(context: Context, attrs: AttributeSet? = null): this(context, attrs, 0)
  
    private var viewStubBinding: ViewStubTestBinding? = null
    private var mListener: XListener? = null
    private val binding: ViewStubLayoutBinding by lazy {
        ViewStubLayoutBinding.inflate(LayoutInflater.from(context), this, false)
    }
  
    init {
        removeAllViews()
        addView(binding.root)

        binding.viewStubLoad.setOnCLickListener {
            if (viewStubBinding == null) {
                val viewStub = binding.viewStubTest
                viewStub.setOnInflateListener { _, inflated ->
                    viewStubBinding = ViewStubTestBinding.bind(inflated)
                }
                viewStub.inflate()
            }
            ...
        }
    }  
  
    private fun callback(tableTool: Int) {  
        mListener?.toolSelect(tableTool)  
    }  

    fun measureHeight() {  
        layoutParams?.height = ResourcesUtil.getDimension(R.dimen.dp_48)
    }

    fun setListener(listener: XListener) {
        mListener = listener
    }
  
    /**  
    * 回调接口  
    */  
    interface XListener {  
        /**
        * 工具类型选择  
        */
        fun toolSelect(eventType: Int)
    }
}

1. DataBinding ViewStub问题

1. 引入
android {  
    ...  
    dataBinding {  
        enabled = true  
    }  
}

2. view_stub_layout.xml  
<?xml version="1.0" encoding="utf-8"?>  
<layout xmlns:android="http://schemas.android.com/apk/res/android">  
  
    <FrameLayout  
        android:layout_width="wrap_content"  
        android:layout_height="wrap_content">  

        <ViewStub  
            android:id="@+id/view_stub_test"  
            android:layout_width="wrap_content"  
            android:layout_height="wrap_content"  
            android:layout="@layout/view_stub_test"/>  
    </FrameLayout>  
</layout>

3. view_stub_test.xml  
<?xml version="1.0" encoding="utf-8"?>  
<layout xmlns:android="http://schemas.android.com/apk/res/android"  
    xmlns:app="http://schemas.android.com/apk/res-auto">  
  
    <LinearLayout  
        android:layout_width="wrap_content"  
        android:layout_height="48dp"  
        android:gravity="center_vertical"  
        android:paddingStart="10dp"  
        android:paddingEnd="10dp"  
        android:paddingTop="8dp"  
        android:paddingBottom="8dp"  
        android:background="@drawable/vector_xxx_bg">  

        <ImageView  
            android:id="@+id/xxx"  
            android:layout_width="32dp"  
            android:layout_height="32dp"  
            android:padding="4dp"  
            android:src="@drawable/vector_xxx"  
            app:layout_constraintStart_toStartOf="parent"  
            app:layout_constraintTop_toTopOf="parent"/>  
    </LinearLayout>  
</layout>  

4. ViewStubLayout.kt
class ViewStubLayout(context: Context, attrs: AttributeSet? = null,
    defAttr: Int = 0, defRes: Int = 0): FrameLayout(context, attrs, defAttr, defRes) {
    constructor(context: Context, attrs: AttributeSet? = null): this(context, attrs, 0)
  
    private lateinit var mViewStubBinding: ViewStubTestBinding
    private var mListener: XListener? = null
    private val mBinding: ViewStubLayoutBinding by lazy {
        ViewStubLayoutBinding.inflate(LayoutInflater.from(context), this, false)
    }
  
    init {
        removeAllViews()
        addView(mBinding.root)

        if (!mBinding.viewStubTest.isInflated) {
            mBinding.viewStubTest.viewStub?.inflate()?.let {
                mViewStubBinding = DataBindingUtil.bind<ViewStubTestBinding>(it)!!
            }
        }
        if (::mViewStubBinding.isInitialized) {
            mViewStubBinding.xxx.setOnClickListener {
                callback(XXX)
            }
        }  
    }  
  
    private fun callback(tableTool: Int) {  
        mListener?.toolSelect(tableTool)  
    }  

    fun measureHeight() {  
        layoutParams?.height = ResourcesUtil.getDimension(R.dimen.dp_48)
    }

    fun setListener(listener: XListener) {
        mListener = listener
    }
  
    /**  
    * 回调接口  
    */  
    interface XListener {  
        /**
        * 工具类型选择  
        */
        fun toolSelect(eventType: Int)
    }
}