• Android — 实现扫码登录功能
  • 发布于 1个月前
  • 54 热度
    0 评论
现在大部分网站都有扫码登录功能,搭配相应的App就能免去输入账号密码实现快速登录。本文简单介绍如何实现扫码登录功能。
实现扫码登录
之前参与过一个电视App的开发,采用扫码登录,需要使用配套的App扫码登录后才能进入到主页。那么扫码登录该怎么实现呢?大致流程如下:
1.被扫端展示一个二维码,二维码包含被扫端的唯一标识(如设备id),并与服务端保持通讯(轮询、长连接、推送)。
2.扫码端扫描二维码之后,使用获取到的被扫端的唯一标识(如设备id)调用服务端扫码登录接口。
3.服务端接收扫码端发起的扫码登录请求,处理(如验证用户信息)后将登录信息发送到被扫端。

PS: 此为大致流程,具体使用需要根据实际需求进行调整。
接下来简单演示一下此流程。
添加依赖库
添加需要的SDK依赖库,在项目app module的build.gradle中的dependencies中添加依赖:
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")
    // 堆代码 duidaima.com
    // 扫描解析、生成二维码
    implementation("com.github.jenly1314:zxing-lite:3.1.0")
}
服务端

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


Socket服务
与被扫端保持通讯,在Http服务接收并处理完扫码登录请求后,将获取到的用户id发送给被扫端。
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判断被扫端是否可以登录。
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服务。
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通信。
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。
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类,简单实现扫描与解析二维码。
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,获取到扫码结果后发送给服务端。
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()
        }
    }
}
示例入口页
提供被扫端和扫码端入口,打开被扫端时同时启动服务端。
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()
    }
}
效果演示与示例代码
最终效果如下图:

用户评论