注册

Android — 实现扫码登录功能

现在大部分网站都有扫码登录功能,搭配相应的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")

// 扫描解析、生成二维码
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()
}
}

效果演示与示例代码


最终效果如下图:


被扫端扫码端
device.gifapp.gif

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


ExampleDemo github


ExampleDemo gitee


作者:ChenYhong
来源:juejin.cn/post/7349545661111336997

0 个评论

要回复文章请先登录注册