Android 悬浮窗功能的实现

简介: Android 悬浮窗功能的实现

前言

我们大多数在两种情况下可以看到悬浮窗,一个是视频通话时的悬浮窗,另一个是360卫士的悬浮球,实现此功能的方式比较多,这里以视频通话悬浮窗中的需求为例。编码实现使用Kotlin。Java版本留言邮箱即可。

业务场景

以微信视频通话为例,在视频通话时,我们打开其他应用或点击Home键退出时或点击缩放图标,悬浮窗会显示在其他应用之上,给人的假象是通话页面变小了,点击悬浮窗回到通过页面,悬浮窗消失。退出通话页面悬浮窗消失。

业务场景技术分析

在编码之前,我们必须将流程整理好,这样更有利于编码的实现。实现一个功能如果需要10分钟,思考的时间是7分钟,编码占用的时间只是三分钟。

1.悬浮窗可以显示在其他应用或launchers之上,这个肯定需要悬浮窗权限,而悬浮窗权限属于特殊权限,所以只能通过引导用户去打开无法像危险权限那样直接申请。可以做到后台显示则说明悬浮窗是一个Service。

2.通话页面隐藏时悬浮窗显示,通话页面显示时悬浮窗隐藏,可以看出悬浮窗和Activity的生命周期相关联,所以悬浮窗的Service和通话页面的Activity是通过bind去绑定的。

3.既然Service和Activity是通过bind去绑定的,说明当悬浮窗显示的时候,通话Activity虽然不可见但仍在运行。

结合上述技术问题分析,我们倒叙一一通过编码实现

悬浮窗实现方案

    • 实现效果

         image.gif

      • 准备工作

            首先我们新建一个项目,项目中有两个Activity,我们在第二个Activity编写通话模拟页面。在第二个页面的原因我们后面会讲到。

        • 如何将acitivity置于后台

        其实很简单,我们调用一个方法即可

        moveTaskToBack(true);

        image.gif

        这个方法的含义就是将当前的任务战置于后台,so,为什么我要在第二个Activity中实现的原因之一,因为默认的Activity的启动模式是标准模式,而上面方法会将任务栈置于后台而不是一个单独的Activity,所以我们为了显示悬浮窗时不影响操作软件的其他功能,我们要将通话页面的Activity设置为singleInstance,这样当调用上面方法的时候只是将通话页面所在的Activity栈置于后台,如果你还不了解启动模式可以移步至上一篇文章:Activity的启动模式

        我们现在在右上方的点击事件中添加上述代码,可以看到通话页面的Activity的已经在后台运行了。

          • 判断是否有悬浮窗权限

          点击左上角图标时,我们要先判断当前app是否有悬浮窗权限,首先我们在配置文件中添加,悬浮窗的权限。

          <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

          image.gif

          (很多文章标题都是悬浮窗如何绕过权限,什么设置类型为TOAST或者PHONE,我想说不可能的事,TOAST类型的虽然部分机型可以显示但是就是一个普通的TOSAT会自动消失)

          那么我们如何判断是否有悬浮窗权限呢,这一块不同厂商处理方案可能不一样,这里我们用一种通用的处理方案,测试表明除了(vivo部分)无效,其他多数机型都ok。并且vivo部分机型微信通话也不会弹出提示(这我就放心了~)

          fun zoom(v: View) {
              if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                  if (!Settings.canDrawOverlays(this)) {
                      Toast.makeText(this, "当前无权限,请授权", Toast.LENGTH_SHORT)
                      GlobalDialogSingle(this, "", "当前未获取悬浮窗权限", "去开启", DialogInterface.OnClickListener { dialog, which ->
                          dialog.dismiss()
                          startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)
                      }).show()
                  } else {
                      moveTaskToBack(true)
                      val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
                      hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
                  }
              }
          }

          image.gif

          我们通过Settings.canDrawOverlays(this)来判断当前应用是否有悬浮窗权限,如果没有,我们弹窗提示,通过

          startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)

          image.gif

          跳转到开启悬浮窗权限页面。如果悬浮窗权限已开启,直接将当前任务栈置于后台,开启服务即可。

          其实回调方法,并没有直接告诉我们是否授权成功,所以我们需要在回调中再次判断

          override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
              if (requestCode == 0) {
                  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                      if (!Settings.canDrawOverlays(this)) {
                          Toast.makeText(this, "授权失败", Toast.LENGTH_SHORT).show()
                      } else {
                          Handler().postDelayed({
                              val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
                              intent.putExtra("rangeTime", rangeTime)
                              hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
                              moveTaskToBack(true)
                          }, 1000)
                      }
                  }
              }
          }

          image.gif

          这里我们可以看到回调中延迟了1秒,因为测试发现某些机型反应“过快”,收到回调的时候还以为没有授权成功,其实已经成功了。

          绑定Service我们需要一个ServiceConnection对象

          internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection {
              override fun onServiceConnected(name: ComponentName, service: IBinder) {
                  // 获取服务的操作对象
                  val binder = service as FloatWinfowServices.MyBinder
                  binder.service
              }
              override fun onServiceDisconnected(name: ComponentName) {}
          }

          image.gif

          Main2Activity的完整代码如下所示:

          /**
           * @author Huanglinqing
           */
          class Main2Activity : AppCompatActivity() {
              private val chronometer: Chronometer? = null
              private var hasBind = false
              private val rangeTime: Long = 0
              override fun onCreate(savedInstanceState: Bundle?) {
                  super.onCreate(savedInstanceState)
                  setContentView(R.layout.activity_main2)
              }
              fun zoom(v: View) {
                  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                      if (!Settings.canDrawOverlays(this)) {
                          Toast.makeText(this, "当前无权限,请授权", Toast.LENGTH_SHORT)
                          GlobalDialogSingle(this, "", "当前未获取悬浮窗权限", "去开启", DialogInterface.OnClickListener { dialog, which ->
                              dialog.dismiss()
                              startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)
                          }).show()
                      } else {
                          moveTaskToBack(true)
                          val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
                          hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
                      }
                  }
              }
              internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection {
                  override fun onServiceConnected(name: ComponentName, service: IBinder) {
                      // 获取服务的操作对象
                      val binder = service as FloatWinfowServices.MyBinder
                      binder.service
                  }
                  override fun onServiceDisconnected(name: ComponentName) {}
              }
              override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
                  if (requestCode == 0) {
                      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                          if (!Settings.canDrawOverlays(this)) {
                              Toast.makeText(this, "授权失败", Toast.LENGTH_SHORT).show()
                          } else {
                              Handler().postDelayed({
                                  val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
                                  intent.putExtra("rangeTime", rangeTime)
                                  hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
                                  moveTaskToBack(true)
                              }, 1000)
                          }
                      }
                  }
              }
              override fun onRestart() {
                  super.onRestart()
                  Log.d("RemoteView", "重新显示了")
                  //不显示悬浮框
                  if (hasBind) {
                      unbindService(mVideoServiceConnection)
                      hasBind = false
                  }
              }
              override fun onNewIntent(intent: Intent) {
                  super.onNewIntent(intent)
              }
              override fun onDestroy() {
                  super.onDestroy()
              }
          }

          image.gif

            • 新建悬浮窗Service

            新建悬浮窗Service FloatWinfowServices,因为我们使用的BindService,我们在onBind方法中初始化service中的布局

            override fun onBind(intent: Intent): IBinder? {
                initWindow()
                //悬浮框点击事件的处理
                initFloating()
                return MyBinder()
            }

            image.gif

            service中我们通过WindowManager来添加一个布局显示。

            /**
             * 初始化窗口
             */
            private fun initWindow() {
                winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager
                //设置好悬浮窗的参数
                wmParams = params
                // 悬浮窗默认显示以左上角为起始坐标
                wmParams!!.gravity = Gravity.LEFT or Gravity.TOP
                //悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0
                wmParams!!.x = winManager!!.defaultDisplay.width
                wmParams!!.y = 210
                //得到容器,通过这个inflater来获得悬浮窗控件
                inflater = LayoutInflater.from(applicationContext)
                // 获取浮动窗口视图所在布局
                mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null)
                // 添加悬浮窗的视图
                winManager!!.addView(mFloatingLayout, wmParams)
            }

            image.gif

            悬浮窗的参数主要设置悬浮窗的类型为

            WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY

            image.gif

            8.0 以下可设置为:

            wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE

            image.gif

            代码如下所示:

            private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上
                    //设置可以显示在状态栏上
                    //设置悬浮窗口长宽数据
            val params: WindowManager.LayoutParams
                get() {
                    wmParams = WindowManager.LayoutParams()
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                        wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
                    } else {
                        wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
                    }
                    wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
                            WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or
                            WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
                    wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
                    wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
                    return wmParams
                }

            image.gif

            当点击悬浮窗的时候回到Activity2页面,并且悬浮窗消失,所以我们只需要给悬浮窗添加点击事件

            linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }

            image.gif

            当Service走到onDestory的时候将view移除,对于Activity2页面来说 当onResume的时候 解绑Service,当onstop的时候 绑定Service。

            从效果图中我们可以看到悬浮窗可以拖拽的,所以还要设置触摸事件,当移动距离超过某个值的时候让onTouch消费事件,这样就不会触发点击事件了。这个算是view比较基础的知识,相信大家都明白了。

            //开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)
            private var mTouchStartX: Int = 0
            private var mTouchStartY: Int = 0
            private var mTouchCurrentX: Int = 0
            private var mTouchCurrentY: Int = 0
            //开始时的坐标和结束时的坐标(相对于自身控件的坐标)
            private var mStartX: Int = 0
            private var mStartY: Int = 0
            private var mStopX: Int = 0
            private var mStopY: Int = 0
            //判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件
            private var isMove: Boolean = false
            private inner class FloatingListener : View.OnTouchListener {
                override fun onTouch(v: View, event: MotionEvent): Boolean {
                    val action = event.action
                    when (action) {
                        MotionEvent.ACTION_DOWN -> {
                            isMove = false
                            mTouchStartX = event.rawX.toInt()
                            mTouchStartY = event.rawY.toInt()
                            mStartX = event.x.toInt()
                            mStartY = event.y.toInt()
                        }
                        MotionEvent.ACTION_MOVE -> {
                            mTouchCurrentX = event.rawX.toInt()
                            mTouchCurrentY = event.rawY.toInt()
                            wmParams!!.x += mTouchCurrentX - mTouchStartX
                            wmParams!!.y += mTouchCurrentY - mTouchStartY
                            winManager!!.updateViewLayout(mFloatingLayout, wmParams)
                            mTouchStartX = mTouchCurrentX
                            mTouchStartY = mTouchCurrentY
                        }
                        MotionEvent.ACTION_UP -> {
                            mStopX = event.x.toInt()
                            mStopY = event.y.toInt()
                            if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {
                                isMove = true
                            }
                        }
                        else -> {
                        }
                    }
                    //如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件
                    return isMove
                }
            }

            image.gif

            FloatWinfowServices所有代码如下所示:

            class FloatWinfowServices : Service() {
                private var winManager: WindowManager? = null
                private var wmParams: WindowManager.LayoutParams? = null
                private var inflater: LayoutInflater? = null
                //浮动布局
                private var mFloatingLayout: View? = null
                private var linearLayout: LinearLayout? = null
                private var chronometer: Chronometer? = null
                override fun onBind(intent: Intent): IBinder? {
                    initWindow()
                    //悬浮框点击事件的处理
                    initFloating()
                    return MyBinder()
                }
                inner class MyBinder : Binder() {
                    val service: FloatWinfowServices
                        get() = this@FloatWinfowServices
                }
                override fun onCreate() {
                    super.onCreate()
                }
                /**
                 * 悬浮窗点击事件
                 */
                private fun initFloating() {
                    linearLayout = mFloatingLayout!!.findViewById<LinearLayout>(R.id.line1)
                    linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }
                    //悬浮框触摸事件,设置悬浮框可拖动
                    linearLayout!!.setOnTouchListener(FloatingListener())
                }
                //开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)
                private var mTouchStartX: Int = 0
                private var mTouchStartY: Int = 0
                private var mTouchCurrentX: Int = 0
                private var mTouchCurrentY: Int = 0
                //开始时的坐标和结束时的坐标(相对于自身控件的坐标)
                private var mStartX: Int = 0
                private var mStartY: Int = 0
                private var mStopX: Int = 0
                private var mStopY: Int = 0
                //判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件
                private var isMove: Boolean = false
                private inner class FloatingListener : View.OnTouchListener {
                    override fun onTouch(v: View, event: MotionEvent): Boolean {
                        val action = event.action
                        when (action) {
                            MotionEvent.ACTION_DOWN -> {
                                isMove = false
                                mTouchStartX = event.rawX.toInt()
                                mTouchStartY = event.rawY.toInt()
                                mStartX = event.x.toInt()
                                mStartY = event.y.toInt()
                            }
                            MotionEvent.ACTION_MOVE -> {
                                mTouchCurrentX = event.rawX.toInt()
                                mTouchCurrentY = event.rawY.toInt()
                                wmParams!!.x += mTouchCurrentX - mTouchStartX
                                wmParams!!.y += mTouchCurrentY - mTouchStartY
                                winManager!!.updateViewLayout(mFloatingLayout, wmParams)
                                mTouchStartX = mTouchCurrentX
                                mTouchStartY = mTouchCurrentY
                            }
                            MotionEvent.ACTION_UP -> {
                                mStopX = event.x.toInt()
                                mStopY = event.y.toInt()
                                if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {
                                    isMove = true
                                }
                            }
                            else -> {
                            }
                        }
                        //如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件
                        return isMove
                    }
                }
                /**
                 * 初始化窗口
                 */
                private fun initWindow() {
                    winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager
                    //设置好悬浮窗的参数
                    wmParams = params
                    // 悬浮窗默认显示以左上角为起始坐标
                    wmParams!!.gravity = Gravity.LEFT or Gravity.TOP
                    //悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0
                    wmParams!!.x = winManager!!.defaultDisplay.width
                    wmParams!!.y = 210
                    //得到容器,通过这个inflater来获得悬浮窗控件
                    inflater = LayoutInflater.from(applicationContext)
                    // 获取浮动窗口视图所在布局
                    mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null)
                    chronometer = mFloatingLayout!!.findViewById<Chronometer>(R.id.chronometer)
                    chronometer!!.start()
                    // 添加悬浮窗的视图
                    winManager!!.addView(mFloatingLayout, wmParams)
                }
                private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上
                        //设置可以显示在状态栏上
                        //设置悬浮窗口长宽数据
                val params: WindowManager.LayoutParams
                    get() {
                        wmParams = WindowManager.LayoutParams()
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                            wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
                        } else {
                            wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
                        }
                        wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
                                WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or
                                WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
                        wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
                        wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
                        return wmParams
                    }
                override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
                    return super.onStartCommand(intent, flags, startId)
                }
                override fun onDestroy() {
                    super.onDestroy()
                    winManager!!.removeView(mFloatingLayout)
                }
            }

            image.gif

              • 实际应用中需要考虑的一些其他问题

              在使用使用的过程中,我们肯定会遇到其他问题:

              1.用户使用过程中,可能会直接按Home键,这个时候如何提示呢?

                产生问题原因:因为用户按Home键之后,开发者无法重写Home键逻辑,此时应用不在前台运行,无法弹窗提醒,此时用户点击APP图标进入的是第一个栈,这个时候用户就没有进入通话页面的入口了。

               解决方案:

               第一种解决方案 我们可以仿照微信那样去做,就是在整个通话过程中开启一个前台通知,用户点击通知时进入通话页面。

               第二种解决方案 就是检测应用是否在前台,当通话页面在运行的时候,并且应用重新回到前台,我们广播到其他页面,提示权限引导即可。

              2.用户在通话页面(singleInstance模式),点击Home键

              应用在后台运行的时候,通话结束,Activity被finish,此时从任务程序中切回应用你会发现打开的竟然是通话页面!

              这个问题简单的说就是,如果你在通话页面呼叫某人,通话过程中按Home键,然后电话挂断,此时你从任务程序中切回应用,会再次呼叫这个人,也就是这种状态下重新回到了onCreate方法。

              问题产生原因:

              1.因为通话页面是singleInstance模式,此时有两个任务栈,按Home键后再从任务程序中切回,此时应用只保留了第二个任务栈,已经失去了和第一个任务栈的关系,finish之后无法在回到第一个任务栈。

              解决方案:

              1.(不推荐)通话页面不使用singleInstance模式,这种情况下,在通话过程中无法操作软件的其他功能,一般都不采取。

              2.(我目前的解决方案)设置一个标记位,标记当前是否在通话,在onCreate中如果通话已经结束了,跳转到一个过渡页面(标准模式),过渡页面中finish,就可以了,添加过渡页面的原因是我们不知道上一个页面是哪里,因为我们收到来电可能是任意页面,我们我们在过渡页面finsh之后,就再次回到了第一个任务栈。

              如果有其他好的解决方案 欢迎留言。

              -------2020年6月2日更新------

              Java版本源码已提交至github

              https://github.com/huanglinqing123/RemoteView

              欢迎start 和Issues

              目录
              相关文章
              |
              2月前
              |
              XML 缓存 Android开发
              Android开发,使用kotlin学习多媒体功能(详细)
              Android开发,使用kotlin学习多媒体功能(详细)
              114 0
              |
              2月前
              |
              安全 Linux Android开发
              Android 安全功能
              Android 安全功能
              53 0
              |
              1月前
              |
              API Android开发 容器
              33. 【Android教程】悬浮窗:PopupWindow
              33. 【Android教程】悬浮窗:PopupWindow
              13 2
              |
              17天前
              |
              数据库 Android开发 数据安全/隐私保护
              在 Android Studio 中结合使用 SQLite 数据库实现简单的注册和登录功能
              在 Android Studio 中结合使用 SQLite 数据库实现简单的注册和登录功能
              61 2
              |
              19天前
              |
              Android开发
              Android中如何快速的实现RecycleView的拖动重排序功能
              使用`ItemTouchHelper`和自定义`Callback`,在`RecyclerView`中实现拖动排序功能。定义`ItemTouchHelperAdapter`接口,`Adapter`实现它以处理`onItemMove`方法。`SimpleItemTouchHelperCallback`设置拖动标志,如`LEFT`或`RIGHT`(水平拖动),并绑定到`RecyclerView`以启用拖动。完成这些步骤后,即可实现拖放排序。关注公众号“AntDream”获取更多内容。
              20 3
              |
              2月前
              |
              移动开发 监控 Android开发
              构建高效Android应用:从内存优化到电池寿命代码之美:从功能实现到艺术创作
              【5月更文挑战第28天】 在移动开发领域,特别是针对Android系统,性能优化始终是关键议题之一。本文深入探讨了如何通过细致的内存管理和电池使用策略,提升Android应用的运行效率和用户体验。文章不仅涵盖了现代Android设备上常见的内存泄漏问题,还提出了有效的解决方案,包括代码级优化和使用工具进行诊断。同时,文中也详细阐述了如何通过减少不必要的后台服务、合理管理设备唤醒锁以及优化网络调用等手段延长应用的电池续航时间。这些方法和技术旨在帮助开发者构建更加健壮、高效的Android应用程序。
              |
              19天前
              |
              存储 数据库 Android开发
              在 Android Studio 中结合使用 SQLite 数据库实现简单的注册和登录功能
              在 Android Studio 中结合使用 SQLite 数据库实现简单的注册和登录功能
              19 0
              |
              2月前
              |
              Android开发 数据安全/隐私保护 iOS开发
              ios和安卓测试包发布网站http://fir.im的注册与常用功能
              ios和安卓测试包发布网站http://fir.im的注册与常用功能
              31 0
              ios和安卓测试包发布网站http://fir.im的注册与常用功能
              |
              2月前
              |
              机器学习/深度学习 人工智能 TensorFlow
              安卓中的人工智能:集成机器学习功能
              【4月更文挑战第14天】在数字化时代,人工智能与机器学习正驱动安卓平台的技术革新。谷歌的ML Kit和TensorFlow Lite为开发者提供了便捷的集成工具,使得应用能实现图像识别、文本转换等功能,提升用户体验。尽管面临数据隐私和安全性的挑战,但随着技术进步,更强大的AI功能将预示着移动端的未来,为开发者创造更多创新机遇。
              |
              2月前
              |
              Android开发
              Android SystemUI去掉拖动亮度条QSPanel界面隐藏功能
              Android SystemUI去掉拖动亮度条QSPanel界面隐藏功能
              35 0