Android NFC使用详解
摘要
网上关于Android NFC 操作的文章没有一篇合适自己,没有从根本解决问题,故整理一翻,理清思路。NFC是Near Field Communication缩写,即近距离无线通讯技术。可以在移动设备、消费类电子产品、PC 和智能控件工具间进行近距离无线通信。比如读取图书标签、公交卡等。
NFC Tag解析顺序(可以不在Activity配置)
nfc类型有三种NDEF,TECH,TAG,优先级依次降低,其对应的Intent过滤规则分别是:
NDEF:
<intent-filter><action android:name = "android.nfc.action.NDEF_DISCOVERED" /><data android:mimeType = "text/plain" />
</intent-filter>
TECH :
<intent-filter> <action android:name="android.nfc.action.TECH_DISCOVERED" /></intent-filter> <meta-data android:name="android.nfc.action.TECH_DISCOVERED"android:resource="@xml/filter_nfc" />
TAG:
<intent-filter><action android:name="android.nfc.action.TAG_DISCOVERED"/><category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
假如有三个Activity分别设置上述三种类型,NFC感应时会依次寻找对应的Activity。
补充说明:Intent Filter就是 用来注册 Activity 、 Service 和 Broadcast Receiver 具有能在某种数据上执行一个动作的能力。
使用 Intent Filter ,应用程序组件告诉 Android ,它们能为其它程序的组件的动作请求提供服务,包括同一个程序的组
件、本地的或第三方的应用程序。
支持的Tag分类
Android为使用的android.nfc.tech包情况提供了通用的支持,android.nfc.tech包如下表中所描述的 。您可以使用的getTechList()方法来确定标签的技术支持,并建立 与之一由android.nfc.tech类相应TagTechnology对象。
Class | Description | 对应识别的卡种 |
---|---|---|
TagTechnology
|
所有标签技术类必须实现的接口。 | |
NfcA
|
提供NFC-A(ISO 14443-3A)的性能和I / O操作的访问。 | m1卡 |
NfcB
|
提供NFC-B (ISO 14443-3B)的性能和I / O操作的访问。 | |
NfcF
|
提供 NFC-F (JIS 6319-4)的性能和I / O操作的访问。 | |
NfcV
|
提供 NFC-V (ISO 15693)的性能和I / O操作的访问。 | 15693卡 |
IsoDep
|
提供 ISO-DEP (ISO 14443-4)的性能和I / O操作的访问。 | CPU卡 |
Ndef
|
提供NFC标签已被格式化为NDEF的数据和操作的访问。 | |
NdefFormatable
|
提供可能被格式化为NDEF的 formattable的标签。 | |
MifareClassic
|
如果此Android设备支持MIFARE,提供访问的MIFARE Classic性能和I / O操作。 | m1卡 |
MifareUltralight
|
如果此Android设备支持MIFARE,提供访问的MIFARE 超轻性能和I / O操作。 |
14443协议和15693协议的区别: 14443是近场耦合,15693是远copy场耦合,14443具有加密功能,15693具有穿透性好,抗干扰性高! 14443按加密的方式分为14443a/b,14443a一般百多用于私用,例如:会员消费卡这类的;14443b因加密特性较好,用于公用多,例如:身份证。 15693常用于高频读距要求知高的场合,例如:货物身份识别、图书标签等
下面讲下不同卡种的概念,捋清思路:
名称 | 类别 | 描述 | 应用 |
M1卡 | 非接触式IC卡 | 有密码,可读可写,价格贵,感应距离短,芯片种类有S50,S70(芯片,是指菲利浦下属子公司恩智浦出品的芯片缩写) | 一卡通,公交等系统 |
CPU卡 | 有接触式也有非接触式的 | 相当于微型计算机,具有用户空间大、读取速度快、支持一卡多用等特点 | 可适用于金融、保险、交警、政府行业等多个领域 |
15693卡 |
非接触IC卡 |
ISO15693是针对射频识别应用的一个国际标准,该标准定义了工作在13.56Mhz下智能标签和读写器的空气接口及数据通信规范,符合此标准的标签最远识读距离达到2米。 | 开放式门禁、开放式会议签到、贵重物品管理、数字化景区门票管理、数字化图书馆图书管理、医药管理、资产管理、产品防伪、物流及供应链等多种领域进行大量应用 |
M1-UltraLight卡 | 非接触式IC卡 | 无密码,又称为MF0,从UltraLight(超轻的)这个名字就可以看出来,它是一个低成本、小容量的卡片。低成本,是指它是目前市场中价格最低的遵守ISO14443A协议的芯片之一;小容量,是指其存储容量只有512bit(Mifare S50有8192bit)。,量分成16个块,每块包含4个字节。Mifare UltraLight的读写操作和 Mifare S50是完全兼容的。 | 门禁、考勤、会议签到、身份识别等 |
NFC使用步骤
1.权限
<uses-permission android:name="android.permission.NFC" /><uses-featureandroid:name="android.hardware.nfc"android:required="true" />
2.在manifest节点下为Activity添加Intent Filter(可不配置)
3.Activity
过程:初始化 -> 设置系统调度 -> 系统调用onNewIntent(Intent intent) -> Tag读写流程 -> 最后取消系统调度
每次检测到Tag时,Activity的生命周期回调onPause()->onNewIntent()-> onResume()
Tag读写流程:
- 建立连接
- 读写
- 关闭连接
1.初始化
mNfcAdapter = NfcAdapter.getDefaultAdapter(this);pi = PendingIntent.getActivity(this, 0, new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), 0);
2.设置系统调度
@Overrideprotected void onResume() {super.onResume();mNfcAdapter.enableForegroundDispatch(this, pi, null, null); //启动}
3.系统调用onNewIntent(Intent intent)
@Overrideprotected void onNewIntent(Intent intent) {super.onNewIntent(intent);handleNfcIntent(intent);}/*** 处理nfc标签** @param intent*/private void handleNfcIntent(Intent intent) {String action = intent.getAction();//根据不同的Action进行获取不同的标签 if (NfcAdapter.ACTION_TAG_DISCOVERED.equals(action)) {//默认Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);processTag(tag);}}
此过程能拿到卡片的uid,方法调用:
Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);//卡片uidString uid=bytes2HexStr(tag.getId());public static String bytes2HexStr(byte[] src) {StringBuilder builder = new StringBuilder();if (src == null || src.length <= 0) {return "";}char[] buffer = new char[2];for (int i = 0; i < src.length; i++) {buffer[0] = Character.forDigit((src[i] >>> 4) & 0x0F, 16);buffer[1] = Character.forDigit(src[i] & 0x0F, 16);builder.append(buffer);}return builder.toString().toUpperCase();}
卡片uid分析:
UID: 全球唯一标识符,每张卡都不一样,8个字节,读写器可以用UID号来操作指定的卡。
以ISO15693卡片为例,假如uid的16进制数据()为:
cabce04b000104e0(逆序分析UID[7]..UID[0])
cabce04b00 01 04 e0
e0:UID[7]为E0是固定的
04:UID[6]为卡制造商编码(如NXP公司为04,TI公司为07,上海贝岭为23)
01:UID[5]为产品类别代码,比如ICODE SL2 ICS20是01H,Tag-it HF-I Plus Chip为80H,Tag-it HF-I Plus Inlay为00H。
cabce04b00:UID[4]..UID[0]才是真正卡号,是制造商内部分配的号码。
打印被读取卡片支持的Tag列表:
for (String tech : tag.getTechList()) {LogUtils.i("------------" + tech);}
4.Tag读写流程
protected void processTag( Tag tag) {IsoDep isodep= IsoDep.get(tag);if (isodep!= null){isodep.connect(); // 建立连接byte[] data = new byte[20];byte[] response = isodep.transceive(data); // 传送消息isoDep.close(); // 关闭连接}}
- 根据实际要读取的卡片支持的Tag,取出对应的class实例,然后进行连接读写操作,如下常见Tag实例。
m1卡: MifareClassic mfc = MifareClassic.get(tag);
iso15693卡:NfcV tech = NfcV.get(tag);
- 发送过去的字节数据,根据实际协议文档进行组装;
- 返回出来的字节数据,根据实际协议文档进行解析
5.取消系统调度
@Overrideprotected void onPause() {if (mNfcAdapter != null)mNfcAdapter.disableForegroundDispatch(this); // 取消调度}
Tag读写工具类
iso15693卡:
package com.haiheng.device.devicedriver.nfc;import android.nfc.tech.NfcV;import com.haiheng.core.util.ByteUtils;import java.io.IOException;/*** NfcV(ISO 15693)读写操作* 用法* NfcV mNfcV = NfcV.get(tag);* mNfcV.connect();* <p>* NfcVUtils mNfcVutil = new NfcVUtils(mNfcV);* 取得UID* mNfcVutil.getUID();* 读取block在1位置的内容* mNfcVutil.readOneBlock(1);* 从位置7开始读2个block的内容* mNfcVutil.readBlocks(7, 2);* 取得block的个数* mNfcVutil.getBlockNumber();* 取得1个block的长度* mNfcVutil.getOneBlockSize();* 往位置1的block写内容* mNfcVutil.writeBlock(1, new byte[]{0, 0, 0, 0})** @author Kelly* @version 1.0.0* @filename NfcVUtils.java* @time 2018/10/30 10:29* @copyright(C) 2018 song*/
public class NfcVUtils {private NfcV mNfcV;/*** UID数组行式*/private byte[] ID;private String UID;private String DSFID;private String AFI;/*** block的个数*/private int blockNumber;/*** 一个block长度*/private int oneBlockSize;/*** 信息*/private byte[] infoRmation;/*** * 初始化* * @param mNfcV NfcV对象* * @throws IOException* */public NfcVUtils(NfcV mNfcV) throws IOException {this.mNfcV = mNfcV;ID = this.mNfcV.getTag().getId();byte[] uid = new byte[ID.length];int j = 0;for (int i = ID.length - 1; i >= 0; i--) {uid[j] = ID[i];j++;}this.UID = ByteUtils.byteArrToHexString(uid);getInfoRmation();}public String getUID() {return UID;}/*** * 取得标签信息 * */private byte[] getInfoRmation() throws IOException {byte[] cmd = new byte[10];cmd[0] = (byte) 0x22; // flagcmd[1] = (byte) 0x2B; // commandSystem.arraycopy(ID, 0, cmd, 2, ID.length); // UIDinfoRmation = mNfcV.transceive(cmd);blockNumber = infoRmation[12];oneBlockSize = infoRmation[13];AFI = ByteUtils.byteArrToHexString(new byte[]{infoRmation[11]});DSFID = ByteUtils.byteArrToHexString(new byte[]{infoRmation[10]});return infoRmation;}public String getDSFID() {return DSFID;}public String getAFI() {return AFI;}public int getBlockNumber() {return blockNumber + 1;}public int getOneBlockSize() {return oneBlockSize + 1;}/*** * 读取一个位置在position的block* * @param position 要读取的block位置* * @return 返回内容字符串* * @throws IOException* */public String readOneBlock(int position) throws IOException {byte cmd[] = new byte[11];cmd[0] = (byte) 0x22;cmd[1] = (byte) 0x20;System.arraycopy(ID, 0, cmd, 2, ID.length); // UIDcmd[10] = (byte) position;byte res[] = mNfcV.transceive(cmd);if (res[0] == 0x00) {byte block[] = new byte[res.length - 1];System.arraycopy(res, 1, block, 0, res.length - 1);return ByteUtils.byteArrToHexString(block);}return null;}/*** * 读取从begin开始end个block* * begin + count 不能超过blockNumber* * @param begin block开始位置* * @param count 读取block数量* * @return 返回内容字符串* * @throws IOException* */public String readBlocks(int begin, int count) throws IOException {if ((begin + count) > blockNumber) {count = blockNumber - begin;}StringBuffer data = new StringBuffer();for (int i = begin; i < count + begin; i++) {data.append(readOneBlock(i));}return data.toString();}/*** * 将数据写入到block,* * @param position 要写内容的block位置* * @param data 要写的内容,必须长度为blockOneSize* * @return false为写入失败,true为写入成功* * @throws IOException * */public boolean writeBlock(int position, byte[] data) throws IOException {byte cmd[] = new byte[15];cmd[0] = (byte) 0x22;cmd[1] = (byte) 0x21;System.arraycopy(ID, 0, cmd, 2, ID.length); // UID//blockcmd[10] = (byte) position;//valueSystem.arraycopy(data, 0, cmd, 11, data.length);byte[] rsp = mNfcV.transceive(cmd);if (rsp[0] == 0x00)return true;return false;}
}
m1卡:
package com.haiheng.hand.device.nfc;import android.nfc.Tag;
import android.nfc.tech.MifareClassic;import com.haiheng.core.util.ByteUtils;
import com.haiheng.core.util.log.LogWriter;import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;/*** m1卡一般16个扇区64块,第一个扇区等闲不能操作。每个扇区4块,每块16字节。* 扇区从0数起,第2扇区第一块索引就是8,每个扇区前3块存数,据最后一块一般存密码。*** @author Kelly* @version 1.0.0* @filename M1CardUtils.java* @time 2019/3/15 14:02* @copyright(C) 2019 song*/
public class M1CardUtils {/*** 读指定扇区的指定块** @param mifareClassic* @param sectorIndex 扇区索引 0~15(16个扇区)* @param blockIndex 块索引 0~3* @param keyA 密钥,可为空,没有使用默认的密钥* @return*/public static byte[] readBlock(MifareClassic mifareClassic, int sectorIndex, int blockIndex,byte[] keyA) throws IOException {try {String metaInfo = "";mifareClassic.connect();int type = mifareClassic.getType();//获取TAG的类型int sectorCount = mifareClassic.getSectorCount();//获取TAG中包含的扇区数String typeS = getMifareClassicType(type);metaInfo += "卡片类型:" + typeS + "\n共" + sectorCount + "个扇区\n共" + mifareClassic.getBlockCount() + "个块\n存储空间: " + mifareClassic.getSize() + "B\n";LogWriter.i(metaInfo);byte[] data = null;String hexString = null;if (m1AuthByKey(mifareClassic, sectorIndex,keyA) || m1Auth(mifareClassic, sectorIndex)){int bCount;int bIndex;bCount = mifareClassic.getBlockCountInSector(sectorIndex);//获得当前扇区的所包含块的数量;bIndex = mifareClassic.sectorToBlock(sectorIndex);//当前扇区的第1块的块号for (int i = 0; i < bCount; i++) {data = mifareClassic.readBlock(bIndex);hexString = ByteUtils.byteArrToHexString(data);LogWriter.w(sectorIndex + "扇区" + bIndex + "块:" + hexString);if (blockIndex == i) {break;}bIndex++;}}else {LogWriter.w("密码校验失败,扇区:" + sectorIndex);}return data;} catch (Exception e) {throw new IOException(e);} finally {try {if (mifareClassic != null) {mifareClassic.close();}} catch (IOException e) {throw new IOException(e);}}}/*** 读指定扇区的所有块** @param mifareClassic* @param sectorIndex 扇区索引 0~15(16个扇区)* @param keyA 密钥,可为空,没有使用默认的密钥* @return*/public static List<byte[]> readBlock(MifareClassic mifareClassic, int sectorIndex, byte[] keyA) throws IOException {List<byte[]> dataList = new ArrayList<byte[]>();try {String metaInfo = "";mifareClassic.connect();int type = mifareClassic.getType();//获取TAG的类型int sectorCount = mifareClassic.getSectorCount();//获取TAG中包含的扇区数String typeS = getMifareClassicType(type);metaInfo += "卡片类型:" + typeS + "\n共" + sectorCount + "个扇区\n共" + mifareClassic.getBlockCount() + "个块\n存储空间: " + mifareClassic.getSize() + "B\n";LogWriter.i(metaInfo);if (m1AuthByKey(mifareClassic, sectorIndex,keyA) || m1Auth(mifareClassic, sectorIndex)){int bCount;int bIndex;bCount = mifareClassic.getBlockCountInSector(sectorIndex);//获得当前扇区的所包含块的数量;bIndex = mifareClassic.sectorToBlock(sectorIndex);//当前扇区的第1块的块号for (int i = 0; i < bCount; i++) {byte[] data = mifareClassic.readBlock(bIndex);String hexString = ByteUtils.byteArrToHexString(data);LogWriter.w(sectorIndex + "扇区" + bIndex + "块:" + hexString);dataList.add(data);bIndex++;}}else {LogWriter.w("密码校验失败,扇区:" + sectorIndex);}return dataList;} catch (Exception e) {throw new IOException(e);} finally {try {if (mifareClassic != null) {mifareClassic.close();}} catch (IOException e) {throw new IOException(e);}}}/*** 读所有扇区的所有块** @param mifareClassic* @param keyA 密钥,可为空,没有使用默认的密钥* @return*/public static Map<Integer,List<byte[]>> readBlock(MifareClassic mifareClassic,byte[] keyA) throws IOException {try {Map<Integer,List<byte[]>> result = new HashMap<Integer,List<byte[]>>();String metaInfo = "";mifareClassic.connect();int type = mifareClassic.getType();//获取TAG的类型int sectorCount = mifareClassic.getSectorCount();//获取TAG中包含的扇区数,一般m1卡扇区数为16个String typeS = getMifareClassicType(type);metaInfo += "卡片类型:" + typeS + "\n共" + sectorCount + "个扇区\n共" + mifareClassic.getBlockCount() + "个块\n存储空间: " + mifareClassic.getSize() + "B\n";LogWriter.i(metaInfo);for (int j = 0; j < sectorCount; j++) {if (m1AuthByKey(mifareClassic, j,keyA) || m1Auth(mifareClassic, j)){List<byte[]> dataList = new ArrayList<byte[]>();int bCount;int bIndex;bCount = mifareClassic.getBlockCountInSector(j);//获得当前扇区的所包含块的数量;bIndex = mifareClassic.sectorToBlock(j);//当前扇区的第1块的块号for (int i = 0; i < bCount; i++) {byte[] data = mifareClassic.readBlock(bIndex);String hexString = ByteUtils.byteArrToHexString(data);LogWriter.w(j + "扇区" + bIndex + "块:" + hexString);dataList.add(data);bIndex++;}result.put(j,dataList);}else {LogWriter.w("密码校验失败,扇区:" + j);}}return result;} catch (Exception e) {throw new IOException(e);} finally {try {if (mifareClassic != null) {mifareClassic.close();}} catch (IOException e) {throw new IOException(e);}}}/*** 获取m1卡类型* @param type* @return*/private static String getMifareClassicType(int type) {String str = null;switch (type) {case MifareClassic.TYPE_CLASSIC:str = "TYPE_CLASSIC";break;case MifareClassic.TYPE_PLUS:str = "TYPE_PLUS";break;case MifareClassic.TYPE_PRO:str = "TYPE_PRO";break;case MifareClassic.TYPE_UNKNOWN:str = "TYPE_UNKNOWN";break;}return str;}/*** 往指定扇区的指定块写数据* @param tag* @param sectorIndex 扇区索引 0~15(16个扇区)* @param blockIndex 块索引 0~63* @param blockByte 写入数据必须是16字节* @return* @throws IOException*/public static boolean writeBlock(Tag tag, int sectorIndex,int blockIndex,byte[] blockByte) throws IOException {MifareClassic mifareClassic = MifareClassic.get(tag);try {mifareClassic.connect();if (m1Auth(mifareClassic, sectorIndex)) {mifareClassic.writeBlock(blockIndex, blockByte);} else {return false;}} catch (IOException e) {throw new IOException(e);} finally {try {mifareClassic.close();} catch (IOException e) {throw new IOException(e);}}return true;}/*** 使用默认密码校验** @param mifareClassic* @param position* @return* @throws IOException*/public static boolean m1Auth(MifareClassic mifareClassic, int position) throws IOException {if (mifareClassic.authenticateSectorWithKeyA(position, MifareClassic.KEY_DEFAULT)) {return true;} else if (mifareClassic.authenticateSectorWithKeyB(position, MifareClassic.KEY_DEFAULT)) {return true;}return false;}/*** 使用指定密码校验** @param mifareClassic* @param position* @param keyA* @return* @throws IOException*/public static boolean m1AuthByKey(MifareClassic mifareClassic, int position,byte[] keyA) throws IOException {if (keyA != null && keyA.length == 6){if(mifareClassic.authenticateSectorWithKeyA(position, keyA)) {return true;}}return false;}
}
特别说明:关于Tag读写只是进行简单工具类封装,没有进行面向组件封装。
配置示例源码:https://github.com/kellysong/android-blog-demo/tree/master/nfc-demo
Android NFC使用详解相关推荐
- Android NFC开发详解 总结和NFC读卡实例解析
文章目录 前言 一.什么是NFC? 二.基础知识 1.什么是NDEF? 2.NFC技术的操作模式 3.标签的技术类型 4.实现方式的分类 5.流程 三.获取标签内容 1.检查环境 2.获取NFC标签 ...
- 《Android游戏开发详解》——第1章,第1.6节函数(在Java中称为“方法”更好)...
本节书摘来自异步社区<Android游戏开发详解>一书中的第1章,第1.6节函数(在Java中称为"方法"更好),作者 [美]Jonathan S. Harbour,更 ...
- JMessage Android 端开发详解
JMessage Android 端开发详解 目前越来越多的应用会需要集成即时通讯功能,这里就为大家详细讲一下如何通过集成 JMessage 来为你的 App 增加即时通讯功能. 首先,一个最基础的 ...
- 《Java和Android开发实战详解》——2.5节良好的Java程序代码编写风格
本节书摘来自异步社区<Java和Android开发实战详解>一书中的第2章,第2.5节良好的Java程序代码编写风格,作者 陈会安,更多章节内容可以访问云栖社区"异步社区&quo ...
- Android事件流程详解
Android事件流程详解 网络上有不少博客讲述了android的事件分发机制和处理流程机制,但是看过千遍,总还是觉得有些迷迷糊糊,因此特地抽出一天事件来亲测下,向像我一样的广大入门程序员详细讲述an ...
- Android Studio 插件开发详解二:工具类
转载请标明出处:http://blog.csdn.net/zhaoyanjun6/article/details/78112856 本文出自[赵彦军的博客] 在插件开发过程中,我们按照开发一个正式的项 ...
- 《Android游戏开发详解》一2.16 区分类和对象
本节书摘来异步社区<Android游戏开发详解>一书中的第2章,第2.16节,作者: [美]Jonathan S. Harbour 译者: 李强 责编: 陈冀康,更多章节内容可以访问云栖社 ...
- Android Framework系统服务详解
Android Framework系统服务详解 操作环境 系统:Linux (Ubuntu 12.04) 平台:高通 Android版本:5.1 PS: 符号...为省略N条代码 一.大致原理分析 A ...
- android屏幕适配详解
android屏幕适配详解 官方地址:http://developer.android.com/guide/practices/screens_support.html 一.关于布局适配建议 1.不要 ...
最新文章
- 【剑指offer-Java版】17合并两个排序链表
- 史上最大“云办公”实验开始,你参加了吗?
- ResNeXt——与 ResNet 相比,相同的参数个数,结果更好:一个 101 层的 ResNeXt 网络,和 200 层的 ResNet 准确度差不多,但是计算量只有后者的一半...
- ListView执行notifyDatasetChanged无数据显示,getView未执行
- 通过一个例子介绍 IDA pro 的简单使用
- 【笔记】JAVA 中国象棋游戏 部分源码
- 02 button的练习
- openresty的安装和使用
- 模拟get和post请求(支持自定义header和测试CDN节点)
- struts2中action手动获取參数
- pve 虚拟环境 vi/vim不能右键粘贴设置方法
- SPSS 逐步回归【SPSS 028期】
- Intellij IDEA企业版破解
- MSSQL父子关系表的SQL查询(SQL Query for Parent Child Relationship)
- 类的封装性、继承性和多态性设计
- [高精度]高精度的封装
- python批量自动化工作
- 模拟【The Biggest Water Problem】+模拟【明明的随机数】
- mybatis 实体嵌套查询
- 2023年首更,警惕6本SCISSCI期刊被剔除
热门文章
- 最新2023基于微信小程序的学生公寓生活管理系统+后台管理系统(SSM+mysql)-JAVA.VUE(毕业设计+论文+开题报告+运行)
- Linux硬件信息查看命令
- 如何给你的社群定位?
- C++ Visual Studio 2017 Error: Cannot open include file: .h: No such file or directory
- 单机网页游戏的如何修改服务器数据库,三国志单机页游网页版一键端+教程及修改工具...
- 网络营销推广是什么_什么样的网站适合做SEO?
- 桌面录屏录音技术,录屏软件原理
- 史上最简单的无缝衔接轮播图
- 「IT人」职业生涯指南——附神级跳槽攻略图!
- BZOJ 4480: [Jsoi2013]快乐的jyy(回文自动机)