Android — 实现扫码登录功能

现在大部分网站都有扫码登录功能,搭配相应的App就能免去输入账号密码实现快速登录。本文简单介绍如何实现扫码登录功能。

实现扫码登录

之前参与过一个电视App的开发,采用扫码登录,需要使用配套的App扫码登录后才能进入到主页。那么扫码登录该怎么实现呢?大致流程如下:

  1. 被扫端展示一个二维码,二维码包含被扫端的唯一标识(如设备id),并与服务端保持通讯(轮询、长连接、推送)。
  2. 扫码端扫描二维码之后,使用获取到的被扫端的唯一标识(如设备id)调用服务端扫码登录接口。
  3. 服务端接收扫码端发起的扫码登录请求,处理(如验证用户信息)后将登录信息发送到被扫端。

PS: 此为大致流程,具体使用需要根据实际需求进行调整。

接下来简单演示一下此流程。

添加依赖库

添加需要的SDK依赖库,在项目app module的build.gradle中的dependencies中添加依赖:

scss 复制代码
dependencies { 
    // 实现服务端(http、socket)
    implementation("org.nanohttpd:nanohttpd:2.3.1") 
    implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
    
    // 与服务端通信
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    
    // 扫描解析、生成二维码
    implementation("com.github.jenly1314:zxing-lite:3.1.0")
}

服务端

使用NanoHttpD实现Socket服务端(与被扫端通信)和Http服务端(与扫码端通信),示例代码如下:

Socket服务

与被扫端保持通讯,在Http服务接收并处理完扫码登录请求后,将获取到的用户id发送给被扫端。

kotlin 复制代码
class ServerSocketClient : NanoWSD(9090) {

    private var serverWebSocket: ServerWebSocket? = null

    override fun openWebSocket(handshake: IHTTPSession?): WebSocket {
        return ServerWebSocket(handshake).also { serverWebSocket = it }
    }

    private class ServerWebSocket(handshake: IHTTPSession?) : WebSocket(handshake) {
        override fun onOpen() {}

        override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {}

        override fun onMessage(message: WebSocketFrame?) {}

        override fun onPong(pong: WebSocketFrame?) {}

        override fun onException(exception: IOException?) {}
    }

    override fun stop() {
        super.stop()
        serverWebSocket = null
    }

    fun sendMessage(message: String) {
        serverWebSocket?.send(message)
    }
}

Http服务

接收并处理来自扫码端的扫码登录请求,通过设备id和用户id判断被扫端是否可以登录。

kotlin 复制代码
const val APP_SCAN_INTERFACE = "loginViaScan"

const val USER_ID = "userId"
const val EXAMPLE_USER_ID = "123456789"

const val DEVICE_ID = "deviceId"
const val EXAMPLE_DEVICE_ID = "example_device_id0001"

class ServerHttpClient(private var scanLoginSucceedListener: ((userId: String) -> Unit)? = null) : NanoHTTPD(8080) {

    override fun serve(session: IHTTPSession?): Response {
        val uri = session?.uri
        return if (uri == "/$APP_SCAN_INTERFACE" &&
            session.parameters[USER_ID]?.first() == EXAMPLE_USER_ID &&
            session.parameters[DEVICE_ID]?.first() == EXAMPLE_DEVICE_ID
        ) {
            scanLoginSucceedListener?.invoke(session.parameters[USER_ID]?.first() ?: "")
            newFixedLengthResponse("Login Succeed")
        } else {
            super.serve(session)
        }
    }
}

服务控制类

启动或停止Socket服务和Http服务。

kotlin 复制代码
object ServerController {

    private var serverSocketClient: ServerSocketClient? = null
    private var serverHttpClient: ServerHttpClient? = null

    fun startServer() {
        (serverSocketClient ?: ServerSocketClient().also {
            serverSocketClient = it
        }).run {
            if (!isAlive) {
                start(0)
            }
        }

        (serverHttpClient ?: ServerHttpClient {
            serverSocketClient?.sendMessage("Login Succeed, user id is $it")
        }.also {
            serverHttpClient = it
        }).run {
            if (!isAlive) {
                start(NanoHTTPD.SOCKET_READ_TIMEOUT, true)
            }
        }
    }

    fun stopServer() {
        serverSocketClient?.stop()
        serverSocketClient = null

        serverHttpClient?.stop()
        serverHttpClient = null
    }
}

被扫端

Socket辅助类

使用OkHttp与服务端进行Socket通信。

kotlin 复制代码
class DevicesSocketHelper(private val messageListener: ((message: String) -> Unit)? = null) {

    private var webSocket: WebSocket? = null

    private val webSocketListener = object : WebSocketListener() {
        override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
            super.onMessage(webSocket, bytes)
            messageListener?.invoke(bytes.utf8())
        }

        override fun onMessage(webSocket: WebSocket, text: String) {
            super.onMessage(webSocket, text)
            messageListener?.invoke(text)
        }
    }

    fun openSocketConnection(serverPath: String) {
        val okHttpClient = OkHttpClient.Builder()
            .connectTimeout(120, TimeUnit.SECONDS)
            .readTimeout(120, TimeUnit.SECONDS)
            .build()
        val request = Request.Builder().url(serverPath).build()
        webSocket = okHttpClient.newWebSocket(request, webSocketListener)
    }

    fun release() {
        webSocket?.close(1000, "")
        webSocket = null
    }
}

被扫端示例页面

先展示二维码,接收到服务端的消息后,显示用户id。

kotlin 复制代码
class DeviceExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutDeviceExampleActivityBinding

    private var socketHelper: DevicesSocketHelper? = DevicesSocketHelper() { message ->
        // 接收到服务端发来的消息,改变显示内容
        runOnUiThread {
            binding.tvUserInfo.text = message
            binding.ivQrCode.visibility = View.GONE
            binding.tvUserInfo.visibility = View.VISIBLE
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutDeviceExampleActivityBinding.inflate(layoutInflater).also {
            setContentView(it.root)
            it.includeTitle.tvTitle.text = "Device Example"
        }

        lifecycleScope.launch(Dispatchers.IO) {
            // 使用设备id生成二维码
            CodeUtils.createQRCode(EXAMPLE_DEVICE_ID, DensityUtil.dp2Px(200)).let { qrCode ->
                withContext(Dispatchers.Main) {
                    binding.ivQrCode.setImageBitmap(qrCode)
                }
            }
        }

        socketHelper?.openSocketConnection("ws://localhost:9090/")
    }

    override fun onDestroy() {
        super.onDestroy()
        socketHelper?.release()
        socketHelper = null
    }
}

扫描端

扫码页

继承zxing-lite库的BarcodeCameraScanActivity类,简单实现扫描与解析二维码。

kotlin 复制代码
class ScanQRCodeActivity : BarcodeCameraScanActivity() {

    override fun initCameraScan(cameraScan: CameraScan<Result>) {
        super.initCameraScan(cameraScan)
        // 播放扫码音效
        cameraScan.setPlayBeep(true)
    }

    override fun createAnalyzer(): Analyzer<Result> {
        return QRCodeAnalyzer(DecodeConfig().apply {
            // 设置仅识别二维码
            setHints(DecodeFormatManager.QR_CODE_HINTS)
        })
    }

    override fun onScanResultCallback(result: AnalyzeResult<Result>) {
        // 已获取结果,停止识别二维码
        cameraScan.setAnalyzeImage(false)
        // 返回扫码结果
        setResult(Activity.RESULT_OK, Intent().apply {
            putExtra(CameraScan.SCAN_RESULT, result.result.text)
        })
        finish()
    }
}

扫描端示例页面

提供扫码入口,提供输入框用于输入服务端IP,获取到扫码结果后发送给服务端。

kotlin 复制代码
class AppScanExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutAppScanExampleActivityBinding

    private var serverIp: String = ""

    private val scanQRCodeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if (it.resultCode == Activity.RESULT_OK) {
            it.data?.getStringExtra(CameraScan.SCAN_RESULT)?.let { deviceId ->
                sendRequestToServer(deviceId)
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutAppScanExampleActivityBinding.inflate(layoutInflater).also {
            setContentView(it.root)
        }

        OkHttpHelper.init()

        binding.btnScan.setOnClickListener {
            // 获取输入的服务端ip(两台设备在同一WIFI下,直接通过IP访问服务端)
            serverIp = binding.etInputIp.text.toString()
            if (serverIp.isEmpty()) {
                showSnakeBar("Server ip can not be empty")
                return@setOnClickListener
            }
            hideKeyboard(binding.etInputIp)
            scanQRCodeLauncher.launch(Intent(this, ScanQRCodeActivity::class.java))
        }
    }

    private fun sendRequestToServer(deviceId: String) {
        OkHttpHelper.sendGetRequest("http://${serverIp}:8080/${APP_SCAN_INTERFACE}", mapOf(Pair(USER_ID, EXAMPLE_USER_ID), Pair(DEVICE_ID, deviceId)), object : RequestCallback {
            override fun onResponse(success: Boolean, responseBody: ResponseBody?) {
                showSnakeBar("Scan login ${if (success) "succeed" else "failure"}")
            }

            override fun onFailure(errorMessage: String?) {
                showSnakeBar("Scan login failure")
            }
        })
    }

    private fun hideKeyboard(view: View) {
        view.clearFocus()
        WindowInsetsControllerCompat(window, view).hide(WindowInsetsCompat.Type.ime())
    }

    private fun showSnakeBar(message: String) {
        runOnUiThread {
            Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
        }
    }
}

示例入口页

提供被扫端和扫码端入口,打开被扫端时同时启动服务端。

kotlin 复制代码
class ScanLoginExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutScanLoginExampleActivityBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutScanLoginExampleActivityBinding.inflate(layoutInflater).also {
            setContentView(it.root)
            it.includeTitle.tvTitle.text = "Scan Login Example"
            it.btnOpenDeviceExample.setOnClickListener {
                // 打开被扫端同时启动服务
                ServerController.startServer()
                startActivity(Intent(this, DeviceExampleActivity::class.java))
            }
            it.btnOpenAppExample.setOnClickListener { startActivity(Intent(this, AppScanExampleActivity::class.java)) }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        ServerController.stopServer()
    }
}

效果演示与示例代码

最终效果如下图:

被扫端 扫码端

演示代码已在示例Demo中添加。

ExampleDemo github

ExampleDemo gitee

相关推荐
HerayChen11 分钟前
HbuildderX运行到手机或模拟器的Android App基座识别不到设备 mac
android·macos·智能手机
顾北川_野12 分钟前
Android 手机设备的OEM-unlock解锁 和 adb push文件
android·java
hairenjing112314 分钟前
在 Android 手机上从SD 卡恢复数据的 6 个有效应用程序
android·人工智能·windows·macos·智能手机
小黄人软件39 分钟前
android浏览器源码 可输入地址或关键词搜索 android studio 2024 可开发可改地址
android·ide·android studio
dj15402252031 小时前
group_concat配置影响程序出bug
android·bug
周全全1 小时前
MySQL报错解决:The user specified as a definer (‘root‘@‘%‘) does not exist
android·数据库·mysql
- 羊羊不超越 -2 小时前
App渠道来源追踪方案全面分析(iOS/Android/鸿蒙)
android·ios·harmonyos
wk灬丨3 小时前
Android Kotlin Flow 冷流 热流
android·kotlin·flow
千雅爸爸3 小时前
Android MVVM demo(使用DataBinding,LiveData,Fresco,RecyclerView,Room,ViewModel 完成)
android