Android自定义View——自定义搜索框(SearchView)

简介:

概述

在Android开发中,当系统数据项比较多时,常常会在app添加搜索功能,方便用户能快速获得需要的数据。搜索栏对于我们并不陌生,在许多app都能见到它,比如豌豆荚

这里写图片描述

在某些情况下,我们希望我们的自动补全信息可以不只是纯文本,还可以像豌豆荚这样,能显示相应的图片和其他数据信息,因此Android给我们提供的AutoCompleteTextView往往就不够用,在大多情况下我们都需要自己去实现搜索框。

分析

根据上面这张图,简单分析一下自定义搜索框的结构与功能,有 
1. 搜索界面大致由三部门组成,如图:输入框+(自动补全)提示框+结果列表。 
2. 提示框的数据与输入框输入的文本是实时联动的,而结果列表只有在每次进行搜索操作时才会更新数据 
3. 输入框的UI应是动态的,即UI随着输入的文本的改变而改变,如:在未输入文本时,清除按钮这里写图片描述应该是隐藏的;只有当框中有文本时才会显示。 
4. 软键盘也应该是动态的,如完成搜索时应自动隐藏。 
5. 选择提示框的选项会自动补全输入框,且自动进行搜索 
6. (external)有热门搜索推荐/记录搜索记录的功能——热门搜索推荐列表只在刚要进行搜索的时候弹出,即未输入文本时,可供用户选择。

根据上面的分析,我们认为一个搜索框应该包含输入框和提示框两个部分。搜索框可以设置一个回调监听接口,当需要进行搜索操作时,调用监听者的search()方法,从而实现具体的搜索操作以及结果列表的数据联动。

演示Demo

这里写图片描述

注意: 
1. 这里,博主图方便没有模拟太多数据,而且提示框和热搜列表也都只是使用String类型的数据,各位看官们可以根据自身需要去设置item_layout和相应的adapter。 
2. 由于个人习惯,博主在这个demo中使用了通用适配器,所以生成和设置adapter的代码比较简略,看官们可以根据传统的ViewHolder模式打造自己的adapter。或者学习一下通用适配器的打造。可以参考这里(鸿神博客Again)学习一下通用适配器的打造,在我的源码里面也有对应的源码。

实现

好了,说了那么多,开始来看代码吧

先看SearchView的布局文件 search_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:background="#eee"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:orientation="vertical">

    <LinearLayout
        android:background="#eb4f38"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">


        <FrameLayout

            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="wrap_content">

            <EditText
                android:id="@+id/search_et_input"
                android:layout_gravity="center_vertical"
                android:layout_margin="10dp"
                android:drawableLeft="@drawable/search_icon"
                android:drawablePadding="5dp"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:background="@drawable/search_edittext_shape"
                android:textSize="16sp"
                android:imeOptions="actionSearch"
                android:inputType="text"
                android:hint="请输入关键字"/>

            <ImageView
                android:visibility="gone"
                android:layout_marginRight="20dp"
                android:src="@drawable/iv_delete_bg"
                android:id="@+id/search_iv_delete"
                android:layout_gravity="right|center_vertical"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
        </FrameLayout>

        <Button
            android:id="@+id/search_btn_back"
            android:layout_marginRight="10dp"
            android:layout_marginTop="10dp"
            android:layout_marginBottom="10dp"
            android:layout_gravity="center_vertical"
            android:background="@drawable/btn_search_bg"
            android:layout_width="@dimen/btn_width"
            android:layout_height="@dimen/btn_height"
            android:text="返回"
            android:textColor="@color/color_white"/>
    </LinearLayout>

    <ListView
        android:visibility="gone"
        android:id="@+id/search_lv_tips"
        android:background="@drawable/lv_search_tips_bg"
        android:layout_marginLeft="20dp"
        android:layout_marginRight="20dp"
        android:layout_marginBottom="10dp"
        android:layout_width="match_parent"
        android:layout_height="200dp">
    </ListView>
</LinearLayout>


注意:demo中颜色什么的都直接用的rgb 值去设置,在实际开发时,需要把它们都统一管理到values目录下 。

比较简单,需要注意的是EditText的这个属性

android:imeOptions="actionSearch" 
就是把Enter键设置为Search键,并把点击Enter键的动作设为actionSearch,这样既可在代码中监听何时按下search键

没什么说的,bg属性可以直接看看源码。接下来看模拟的bean类,这里直接就叫Bean.java

public class Bean {

    private int iconId;
    private String title;
    private String content;
    private String comments;

    public Bean(int iconId, String title, String content, String comments) {
        this.iconId = iconId;
        this.title = title;
        this.content = content;
        this.comments = comments;
    }

    public int getIconId() {
        return iconId;
    }

    public void setIconId(int iconId) {
        this.iconId = iconId;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public String getComments() {
        return comments;
    }

    public void setComments(String comments) {
        this.comments = comments;
    }
}

接着看主角SearchView.java

public class SearchView extends LinearLayout implements View.OnClickListener {

    /**
     * 输入框
     */
    private EditText etInput;

    /**
     * 删除键
     */
    private ImageView ivDelete;

    /**
     * 返回按钮
     */
    private Button btnBack;

    /**
     * 上下文对象
     */
    private Context mContext;

    /**
     * 弹出列表
     */
    private ListView lvTips;

    /**
     * 提示adapter (推荐adapter)
     */
    private ArrayAdapter<String> mHintAdapter;

    /**
     * 自动补全adapter 只显示名字
     */
    private ArrayAdapter<String> mAutoCompleteAdapter;

    /**
     * 搜索回调接口
     */
    private SearchViewListener mListener;

    /**
     * 设置搜索回调接口
     *
     * @param listener 监听者
     */
    public void setSearchViewListener(SearchViewListener listener) {
        mListener = listener;
    }

    public SearchView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        LayoutInflater.from(context).inflate(R.layout.search_layout, this);
        initViews();
    }

    private void initViews() {
        etInput = (EditText) findViewById(R.id.search_et_input);
        ivDelete = (ImageView) findViewById(R.id.search_iv_delete);
        btnBack = (Button) findViewById(R.id.search_btn_back);
        lvTips = (ListView) findViewById(R.id.search_lv_tips);

        lvTips.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
                //set edit text
                String text = lvTips.getAdapter().getItem(i).toString();
                etInput.setText(text);
                etInput.setSelection(text.length());
                //hint list view gone and result list view show
                lvTips.setVisibility(View.GONE);
                notifyStartSearching(text);
            }
        });

        ivDelete.setOnClickListener(this);
        btnBack.setOnClickListener(this);

        etInput.addTextChangedListener(new EditChangedListener());
        etInput.setOnClickListener(this);
        etInput.setOnEditorActionListener(new TextView.OnEditorActionListener() {
            @Override
            public boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) {
                if (actionId == EditorInfo.IME_ACTION_SEARCH) {
                    lvTips.setVisibility(GONE);
                    notifyStartSearching(etInput.getText().toString());
                }
                return true;
            }
        });
    }

    /**
     * 通知监听者 进行搜索操作
     * @param text
     */
    private void notifyStartSearching(String text){
        if (mListener != null) {
            mListener.onSearch(etInput.getText().toString());
        }
        //隐藏软键盘
        InputMethodManager imm = (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE);
        imm.toggleSoftInput(0, InputMethodManager.HIDE_NOT_ALWAYS);
    }

    /**
     * 设置热搜版提示 adapter
     */
    public void setTipsHintAdapter(ArrayAdapter<String> adapter) {
        this.mHintAdapter = adapter;
        if (lvTips.getAdapter() == null) {
            lvTips.setAdapter(mHintAdapter);
        }
    }

    /**
     * 设置自动补全adapter
     */
    public void setAutoCompleteAdapter(ArrayAdapter<String> adapter) {
        this.mAutoCompleteAdapter = adapter;
    }

    private class EditChangedListener implements TextWatcher {
        @Override
        public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) {

        }

        @Override
        public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {
            if (!"".equals(charSequence.toString())) {
                ivDelete.setVisibility(VISIBLE);
                lvTips.setVisibility(VISIBLE);
                if (mAutoCompleteAdapter != null && lvTips.getAdapter() != mAutoCompleteAdapter) {
                    lvTips.setAdapter(mAutoCompleteAdapter);
                }
                //更新autoComplete数据
                if (mListener != null) {
                    mListener.onRefreshAutoComplete(charSequence + "");
                }
            } else {
                ivDelete.setVisibility(GONE);
                if (mHintAdapter != null) {
                    lvTips.setAdapter(mHintAdapter);
                }
                lvTips.setVisibility(GONE);
            }

        }

        @Override
        public void afterTextChanged(Editable editable) {
        }
    }

    @Override
    public void onClick(View view) {
        switch (view.getId()) {
            case R.id.search_et_input:
                lvTips.setVisibility(VISIBLE);
                break;
            case R.id.search_iv_delete:
                etInput.setText("");
                ivDelete.setVisibility(GONE);
                break;
            case R.id.search_btn_back:
                ((Activity) mContext).finish();
                break;
        }
    }

    /**
     * search view回调方法
     */
    public interface SearchViewListener {

        /**
         * 更新自动补全内容
         *
         * @param text 传入补全后的文本
         */
        void onRefreshAutoComplete(String text);

        /**
         * 开始搜索
         *
         * @param text 传入输入框的文本
         */
        void onSearch(String text);

//        /**
//         * 提示列表项点击时回调方法 (提示/自动补全)
//         */
//        void onTipsItemClick(String text);
    }

}

搜索框主要包含两个结构:输入栏+弹出框(自动补全或热门搜素推荐)。

代码不多,实现很简单,主要是需要给EditText(输入框)设置点击监听和文本改变监听,有以下几点: 
1. 当输入框没有文本时,点击输入框,显示热门搜索列表框。 
2. 当输入框有文本时,点击输入框,应显示自动补全列表框。 
3. 当输入框的文本发生改变时,需要更新自动补全列表框的数据。由于这些数据应该是在外部(调用者)中获得的,所以可以通过接口回调的形式,当需要更新时,通知监听者更新数据。 
4. 当输入框的文本从空”“变换到非空时,即有字符时,界面应显示自动补全框,隐藏热门搜索框。 
5. 当输入框的文本从非空变为空时,系统应隐藏自动补全框和热门搜索框。 
6. 需要监听是否按下search键(enter),按下时通知监听者执行search操作

结合以上6点和在上文分析过的内容,就能很轻松地实现该view。

之后来看看搜索界面的布局文activity_main.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:tools="http://schemas.android.com/tools"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              tools:context=".MainActivity"
              android:orientation="vertical">

    <com.yetwish.customsearchdemo.activity.widge.SearchView
        android:id="@+id/main_search_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
    </com.yetwish.customsearchdemo.activity.widge.SearchView>

    <ListView
        android:visibility="gone"
        android:id="@+id/main_lv_search_results"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

    </ListView>
</LinearLayout>

就是一个SearchView加上一个结果列表,这些我们在上文都分析过了,所以也没什么好说的。布局可根据自身需求去自定义。

最后就是搜索界面调用该view  MainActiviy.java

public class MainActivity extends Activity implements SearchView.SearchViewListener {

    /**
     * 搜索结果列表view
     */
    private ListView lvResults;

    /**
     * 搜索view
     */
    private SearchView searchView;


    /**
     * 热搜框列表adapter
     */
    private ArrayAdapter<String> hintAdapter;

    /**
     * 自动补全列表adapter
     */
    private ArrayAdapter<String> autoCompleteAdapter;

    /**
     * 搜索结果列表adapter
     */
    private SearchAdapter resultAdapter;

    /**
     * 数据库数据,总数据
     */
    private List<Bean> dbData;

    /**
     * 热搜版数据
     */
    private List<String> hintData;

    /**
     * 搜索过程中自动补全数据
     */
    private List<String> autoCompleteData;

    /**
     * 搜索结果的数据
     */
    private List<Bean> resultData;

    /**
     * 默认提示框显示项的个数
     */
    private static int DEFAULT_HINT_SIZE = 4;

    /**
     * 提示框显示项的个数
     */
    private static int hintSize = DEFAULT_HINT_SIZE;

    /**
     * 设置提示框显示项的个数
     *
     * @param hintSize 提示框显示个数
     */
    public static void setHintSize(int hintSize) {
        MainActivity.hintSize = hintSize;
    }


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.activity_main);
        initData();
        initViews();
    }

    /**
     * 初始化视图
     */
    private void initViews() {
        lvResults = (ListView) findViewById(R.id.main_lv_search_results);
        searchView = (SearchView) findViewById(R.id.main_search_layout);
        //设置监听
        searchView.setSearchViewListener(this);
        //设置adapter
        searchView.setTipsHintAdapter(hintAdapter);
        searchView.setAutoCompleteAdapter(autoCompleteAdapter);

        lvResults.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> adapterView, View view, int position, long l) {
                Toast.makeText(MainActivity.this, position + "", Toast.LENGTH_SHORT).show();
            }
        });
    }

    /**
     * 初始化数据
     */
    private void initData() {
        //从数据库获取数据
        getDbData();
        //初始化热搜版数据
        getHintData();
        //初始化自动补全数据
        getAutoCompleteData(null);
        //初始化搜索结果数据
        getResultData(null);
    }

    /**
     * 获取db 数据
     */
    private void getDbData() {
        int size = 100;
        dbData = new ArrayList<>(size);
        for (int i = 0; i < size; i++) {
            dbData.add(new Bean(R.drawable.icon, "android开发必备技能" + (i + 1), "Android自定义view——自定义搜索view", i * 20 + 2 + ""));
        }
    }

    /**
     * 获取热搜版data 和adapter
     */
    private void getHintData() {
        hintData = new ArrayList<>(hintSize);
        for (int i = 1; i <= hintSize; i++) {
            hintData.add("热搜版" + i + ":Android自定义View");
        }
        hintAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, hintData);
    }

    /**
     * 获取自动补全data 和adapter
     */
    private void getAutoCompleteData(String text) {
        if (autoCompleteData == null) {
            //初始化
            autoCompleteData = new ArrayList<>(hintSize);
        } else {
            // 根据text 获取auto data
            autoCompleteData.clear();
            for (int i = 0, count = 0; i < dbData.size()
                    && count < hintSize; i++) {
                if (dbData.get(i).getTitle().contains(text.trim())) {
                    autoCompleteData.add(dbData.get(i).getTitle());
                    count++;
                }
            }
        }
        if (autoCompleteAdapter == null) {
            autoCompleteAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, autoCompleteData);
        } else {
            autoCompleteAdapter.notifyDataSetChanged();
        }
    }

    /**
     * 获取搜索结果data和adapter
     */
    private void getResultData(String text) {
        if (resultData == null) {
            // 初始化
            resultData = new ArrayList<>();
        } else {
            resultData.clear();
            for (int i = 0; i < dbData.size(); i++) {
                if (dbData.get(i).getTitle().contains(text.trim())) {
                    resultData.add(dbData.get(i));
                }
            }
        }
        if (resultAdapter == null) {
            resultAdapter = new SearchAdapter(this, resultData, R.layout.item_bean_list);
        } else {
            resultAdapter.notifyDataSetChanged();
        }
    }

    /**
     * 当搜索框 文本改变时 触发的回调 ,更新自动补全数据
     * @param text
     */
    @Override
    public void onRefreshAutoComplete(String text) {
        //更新数据
        getAutoCompleteData(text);
    }

    /**
     * 点击搜索键时edit text触发的回调
     *
     * @param text
     */
    @Override
    public void onSearch(String text) {
        //更新result数据
        getResultData(text);
        lvResults.setVisibility(View.VISIBLE);
        //第一次获取结果 还未配置适配器
        if (lvResults.getAdapter() == null) {
            //获取搜索数据 设置适配器
            lvResults.setAdapter(resultAdapter);
        } else {
            //更新搜索数据
            resultAdapter.notifyDataSetChanged();
        }
        Toast.makeText(this, "完成搜素", Toast.LENGTH_SHORT).show();
    }

}


使用SearchView比较简单,只要给SearchView设置onSearchViewListener监听接口,实现对应的方法,并给SearchView传入热搜版和自动补全的adapter既可。

这里使用的匹配算法比较简单,也没有考虑多个搜索词的情况,(这些之后都可以再完善),主要实现就是在总数据中匹配每个Bean的Title是否包含搜索词,包含则表示该数据匹配,否则不匹配。然后将所有匹配的Bean显示到结果列表中。

考虑到实际开发中,数据量十分庞大,可以只把结果集的一部分(如前10个)显示出来,上拉到底的时候再加载之后的记录,也就是可以加入上拉加载的机制,使app性能更优化。

自动补全匹配也是采用相同的算法。算法都比较简单,当然也可以弄得复杂点,比如根据“ ”(空格)去分割输入文本,再逐个考虑单个搜索词的匹配项,把匹配次数从多到少排列出结果集等等。这里不细说。

这里有一个问题是进入该搜索界面时需要加载所有的数据项到内存,当数据项很多时,是否会占用大量的内存?如果是应该如何避免?是采用只加载一部分数据的形式,还是直接使用搜索词到数据库中查询更优?还请各位看官大神们给出宝贵的意见~

好了,自定义搜索框到这就打造完成啦,是不是感觉简单过头了。

各位看官如果有任何问题可评论或者发邮件跟我联系yetwish@gmail.com

囧~忘记贴代码了,代码放在github上,各位看官直接download即可 
链接:https://github.com/yetwish/CustomSearchView

相关文章
|
3天前
|
API Android开发 开发者
Android经典实战之使用ViewCompat来处理View兼容性问题
本文介绍Android中的`ViewCompat`工具类,它是AndroidX库核心部分的重要兼容性组件,确保在不同Android版本间处理视图的一致性。文章列举了设置透明度、旋转、缩放、平移等功能,并提供了背景色、动画及用户交互等实用示例。通过`ViewCompat`,开发者可轻松实现跨版本视图操作,增强应用兼容性。
20 5
|
18天前
|
XML 前端开发 Android开发
Android面试高频知识点(3) 详解Android View的绘制流程
View的绘制和事件处理是两个重要的主题,上一篇《图解 Android事件分发机制》已经把事件的分发机制讲得比较详细了,这一篇是针对View的绘制,View的绘制如果你有所了解,基本分为measure、layout、draw 过程,其中比较难理解就是measure过程,所以本篇文章大幅笔地分析measure过程,相对讲得比较详细,文章也比较长,如果你对View的绘制还不是很懂,对measure过程掌握得不是很深刻,那么耐心点,看完这篇文章,相信你会有所收获的。
39 2
|
1月前
|
Android开发
Android面试题之自定义View注意事项
在Android开发中,自定义View主要分为四类:直接继承View重写onDraw,继承ViewGroup创建布局,扩展特定View如TextView,以及继承特定ViewGroup。实现时需注意:支持wrap_content通过onMeasure处理,支持padding需在onDraw或onMeasure/onLayout中处理。避免在View中使用Handler,使用post系列方法代替。记得在onDetachedFromWindow时停止线程和动画以防止内存泄漏。处理滑动嵌套时解决滑动冲突,并避免在onDraw中大量创建临时对象。
23 4
|
1月前
|
Android开发
Android面试题之View的invalidate方法和postInvalidate方法有什么区别
本文探讨了Android自定义View中`invalidate()`和`postInvalidate()`的区别。`invalidate()`在UI线程中刷新View,而`postInvalidate()`用于非UI线程,通过消息机制切换到UI线程执行`invalidate()`。源码分析显示,`postInvalidate()`最终调用`ViewRootImpl`的`dispatchInvalidateDelayed`,通过Handler发送消息到UI线程执行刷新。
27 1
|
19天前
|
机器学习/深度学习 人工智能 算法
探索AI在医疗影像分析中的应用探索安卓开发中的自定义View组件
【7月更文挑战第31天】随着人工智能技术的飞速发展,其在医疗健康领域的应用日益广泛。本文将聚焦于AI技术在医疗影像分析中的运用,探讨其如何通过深度学习模型提高诊断的准确性和效率。我们将介绍一些关键的深度学习算法,并通过实际代码示例展示这些算法是如何应用于医学影像的处理和分析中。文章旨在为读者提供对AI在医疗领域应用的深刻理解和实用知识。
22 0
|
1月前
|
前端开发 API Android开发
Android自定义View之Canvas一文搞定
这篇文章介绍了Android自定义View中如何使用Canvas和Paint来绘制图形。Canvas可理解为画布,用于绘制各种形状如文字、点、线、矩形、圆角矩形、圆和弧。常见API包括`drawText()`、`drawPoint()`、`drawLine()`、`drawRect()`等。文章还提到了Canvas的保存、恢复、平移和旋转方法,通过绘制钟表盘的例子展示了如何实际应用。总结关键点:Canvas与Paint结合用于图像绘制,掌握Canvas的基本绘图函数及坐标变换操作是自定义View的关键。
23 0
Android自定义View之Canvas一文搞定
|
26天前
|
消息中间件 调度 Android开发
Android经典面试题之View的post方法和Handler的post方法有什么区别?
本文对比了Android开发中`View.post`与`Handler.post`的使用。`View.post`将任务加入视图关联的消息队列,在视图布局后执行,适合视图操作。`Handler.post`更通用,可调度至特定Handler的线程,不仅限于视图任务。选择方法取决于具体需求和上下文。
26 0
|
1月前
|
消息中间件 前端开发 Android开发
Android面试题自定义View之Window、ViewRootImpl和View的三大流程
Android开发中,View的三大核心流程包括measure(测量)、layout(布局)和draw(绘制)。MeasureSpec类在测量过程中起到关键作用,它结合尺寸大小和模式(EXACTLY、AT_MOST、UNSPECIFIED)来指定View应如何测量。onMeasure方法用于自定义View的测量,布局阶段,ViewGroup调用onLayout确定子元素位置,而draw阶段按照特定顺序绘制背景、内容、子元素和装饰。整个流程始于ViewRootImpl的performTraversals,该方法触发测量、布局和绘制。
24 0
|
Android开发
Android Material 搜索框实现详细说明
本文讲的是Android Material 搜索框实现详细说明,关于我的应用,我收到了一些用户的反馈,他们反馈最多的是缺少搜索功能。对于像 Memento Calendar 这种囊括了诸如社交时间,纪念日,银行休假日,信息来源错综复杂的应用,我很赞同搜索是这个应用最重要的功能之一。
1458 0
|
1天前
|
JavaScript 前端开发 Java
FFmpeg开发笔记(四十七)寒冬下安卓程序员的几个技术转型发展方向
IT寒冬使APP开发门槛提升,安卓程序员需转型。选项包括:深化Android开发,跟进Google新技术如Kotlin、Jetpack、Flutter及Compose;研究Android底层框架,掌握AOSP;转型Java后端开发,学习Spring Boot等框架;拓展大前端技能,掌握JavaScript、Node.js、Vue.js及特定框架如微信小程序、HarmonyOS;或转向C/C++底层开发,通过音视频项目如FFmpeg积累经验。每条路径都有相应的书籍和技术栈推荐,助你顺利过渡。
11 3
FFmpeg开发笔记(四十七)寒冬下安卓程序员的几个技术转型发展方向