最近接到领导的一个需求,需要通过Android 端直接控制局域网打印机进行打印,一开始查阅了很多资料包括各大品牌官网开发者文档,最后终于实现了,这篇文章就简单总结下,目前在Android 应用层通过局域网Wi-Fi快速调用家用打印机(首先得支持无线打印的功能,最好还是Mopria联盟的成员及认证机器)进行打印实现方式主要有三种,接下来将一一介绍。
大概是在Android API 19 之后,Android 在V4兼容包下提供了一个名为android.support.v4.print打印支持包,通过官方的调用包下对应的API是可以快速实现局域网Wi-Fi调用家用打印机完成图片或者文档的打印的,不过呢Google 官方并没有那么友好,提前帮你适配各种打印机的驱动,因此这个库正常工作的前提是需要依赖各品牌官方或者第三方集成商提供的打印服务插件(比如第三方Mopria PrintService、惠普提供的 HP Print Service、佳能提供的 Canon Print Service等等),至于使用何种插件取决于你自己,两者互有优劣,第三方集成的服务在于兼容品牌多,但是有些型号可能没有对应的驱动支持,而各品牌官方的优势则在于可以完美适配对应品牌型号的打印机,缺点就是各厂家之间不能通用,要想使用哪种品牌的就得安装对应的打印服务插件。
通常这些所谓的服务插件都是以APK的形式提供的,有条件的话到Google play 官网上去下载,当然国内各大应用商店都有直接输入英文搜索就行,千万不要去那种垃圾的网站去下载什么所谓的完美破解版,都是些挂羊头卖狗肉的垃圾,下载完毕在之后安装,可以通过代码静默安装也可以引导安装,安装完毕之后还需要先到设置界面中开启对应的服务,开启对应服务之后,当我们需要打印时,他们会去帮我完成连接打印机等一系列准备工作(比如说提供搜索Wi-Fi下同一网段的打印机适配驱动等等),我们只需要调用对应的API传入要打印的数据即可。
直接调用android.support.v4.print.PrintHelper中**systemSupportsPrint()**的方法
Log.e("cmo","是否支持:"+PrintHelper.systemSupportsPrint());
private void photoPrint() {
//初始化创建PrintHelper对象
PrintHelper photoPrinter = new PrintHelper(this);
photoPrinter.setScaleMode(PrintHelper.SCALE_MODE_FIT);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),R.mipmap.ic_launcher_round);
//第一个参数为jobName 任意字符串,建议可以使用随机字符串,下同
photoPrinter.printBitmap("cmo:photoPrint", bitmap);
}
package com.crazymo.printer;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.print.PageRange;
import android.print.PrintAttributes;
import android.print.PrintDocumentAdapter;
import android.print.PrintDocumentInfo;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* @author crazy.mo
*/
public class MoPrintPdfAdapter extends PrintDocumentAdapter {
private String mFilePath;
public MoPrintPdfAdapter(String file) {
this.mFilePath = file;
}
@Override
public void onLayout(PrintAttributes oldAttributes, PrintAttributes newAttributes, CancellationSignal cancellationSignal,
LayoutResultCallback callback, Bundle extras) {
if (cancellationSignal.isCanceled()) {
callback.onLayoutCancelled();
return;
}
PrintDocumentInfo info = new PrintDocumentInfo.Builder(getJobName())
.setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
.build();
callback.onLayoutFinished(info, true);
}
@Override
public void onWrite(PageRange[] pages, ParcelFileDescriptor destination, CancellationSignal cancellationSignal,
WriteResultCallback callback) {
InputStream input = null;
OutputStream output = null;
try {
input = new FileInputStream(mFilePath);
output = new FileOutputStream(destination.getFileDescriptor());
byte[] buf = new byte[1024];
int bytesRead;
while ((bytesRead = input.read(buf)) > 0) {
output.write(buf, 0, bytesRead);
}
callback.onWriteFinished(new PageRange[]{PageRange.ALL_PAGES});
} catch (FileNotFoundException e) {
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
input.close();
output.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private String getJobName() {
try {
String[] filePaths = mFilePath.split(File.separator);
return filePaths[filePaths.length - 1];
} catch (Exception e) {
e.printStackTrace();
}
return String.valueOf(System.currentTimeMillis());
}
}
把View 转为PDF,此处需要注意View 必须是已经渲染加载完毕之后,否则无法把内容写入到PDF中,此处为简单Demo,实际项目中建议使用线程池替代这种独立创建线程的方式,另外对于Android 6.0及以上版本需要处理动态权限,下同。
/**
* 把View转为PDF,必须要在View 渲染完毕之后
* 1.使用LayoutInflater反射出来的View不行;
* 2. 将要转换成pdf的xml view文件include到一个界面中,将其设置成android:visibility=”invisible”就可以实现,不显示,但是能转换成PDF;
* 3. 设置成gone不行;
* @param view
* @param pdfName
*/
private void createPdfFromView(@NonNull View view, @NonNull final String pdfName ){
//1, 建立PdfDocument
final PdfDocument document = new PdfDocument();
PdfDocument.PageInfo pageInfo = new PdfDocument.PageInfo
.Builder(view.getMeasuredWidth() , view.getMeasuredHeight(), 1)
//设置绘制的内容区域,此处我预留了10的内边距
.setContentRect(new Rect(10,10,view.getMeasuredWidth()-10,view.getMeasuredHeight()-10))
.create();
PdfDocument.Page page = document.startPage(pageInfo);
view.draw(page.getCanvas());
//必须在close 之前调用,通常是在最后一页调用
document.finishPage(page);
//保存至SD卡
new Thread(new Runnable() {
@Override
public void run() {
try {
String path = Environment.getExternalStorageDirectory() + File.separator + pdfName;
File e = new File(path);
if (e.exists()) {
e.delete();
}
document.writeTo(new FileOutputStream(e));
document.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
/**
* 用系统框架打印PDF
* @param filePath
*/
private void doPdfPrint(String filePath) {
String jobName = "jobName";
PrintManager printManager = (PrintManager) getSystemService(Context.PRINT_SERVICE);
MoPrintPdfAdapter myPrintAdapter = new MoPrintPdfAdapter(filePath);
// 设置打印参数
PrintAttributes attributes = new PrintAttributes.Builder()
.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
.setResolution(new PrintAttributes.Resolution("id", Context.PRINT_SERVICE, 480, 320))
.setColorMode(PrintAttributes.COLOR_MODE_COLOR)
.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
.build();
printManager.print(jobName, myPrintAdapter, attributes);
}
欲了解更多请参见官方文档
所谓移花接木其实本质上是一种投机取巧的方式,主要思路就是在自己的APP中调用另一个APP的提供的打印功能,目前比较好用的APP有随行打印 PrintHand(新版本还提供了大量品牌对应的专用驱动及通用驱动,对于一些型号的打印机来说通用驱动也是可以完美支持的)、PrintShare 、品牌官方提供的手机打印APP,其中PrintHand 内部中使用PrintShare的代码,并进行了优化和扩展,所以呢推荐使用PrintHand,如果有条件的话建议到Google Play上去购买下载收费版,千万不要去国内搜索引擎搜索下载所谓的破解版,因为我已经找了很多资源网站上都没有收费破解版的(如果找到了不妨分享大家下),有些甚至是根本不能用,哪些资源网站还能再无耻一点,安装完毕之后,借助第三方APP的方式来实现打印,好处在于可以进行很多个性化的设置和简单便捷对接,但无法主动掌控打印流程,核心思想就是在我们自己的APP中匿名启动另一个APP的Activity
public class ActivityHelper {
/**
*PrintHand 打印ApplicationId
*/
public static final String APPID_PRINTHAND = "com.dynamixsoftware.printhand";
public static final String MAIN_PRINTHAND = APPID_PRINTHAND+".ui.ActivityMain";
/**
*惠普打印ApplicationId
*/
public static final String APPID_HP="com.hp.printercontrol";
public static final String MAIN_HP = APPID_HP+".base.PrinterControlActivity";
/**
* 通过应用的包名和对应的Activity全类名启动任意一个Activity(可以跨进程)
* 如果该Activity非应用入口(入口Activity默认android:exported="true"),则需要再清单文件中添加 android:exported="true"。
* Service也需要添加android:exported="true"。允许外部应用调用。
* @param pkg 应用的包名即AppcationId
* @param cls 要启动的Activity 全类名
*/
public static void startActivityByComponentName(Context context, String pkg, String cls) {
ComponentName comp = new ComponentName(pkg,cls);
Intent intent = new Intent();
intent.setComponent(comp);
intent.setAction("android.intent.action.VIEW");
intent.setAction("android.intent.action.SEND");
intent.addCategory("android.intent.category.DEFAULT");
context.startActivity(intent);
}
}
打开PrintHand的打印界面,无论是想要打开哪个APP的界面,你得先安装对应的APP,然后去获取对应的APPLICATIONID和对应Activity的信息,最后通过匿名启动方式启动即可。
ActivityHelper.startActivityByComponentName(this, ActivityHelper.APPID_PRINTHAND, ActivityHelper.MAIN_PRINTHAND);
启动了第三方APP的打印界面之后,就相当于是把打印任务交到别人手上了,至于如何操作是第三方APP的事了。对了,我查阅了惠普打印机开发者官网,发现在Android8.0之后自动集成了惠普远程打印的功能,因为惠普不提供打印的SDK了。
这种形式是最本质的实现远程打印的原理,绝大部分第三方APP都是基于Socket通信的封装而已。原来我以为每个厂家应该都会定制了专属于自己的私有的网络通信协议,没想到竟然是最简答的Socket就能通过同一网段进行访问,是最简单的C/S架构,可以把打印机看成S层,所以我们想通过手机去向打印机发出请求,只需要拿到打印机的IP和对应的端口号,使用Sokect去通信即可,因为同一网段的打印机只要接收到网段内的Sokect请求就会接收,如此我们便可以拿到Sokect的输出流OutputStream,于是打印则演变成为了向OutputStream 写入数据,有些打印机还支持输入流InputStream,通过InputStream我们或许还可以拿到打印机的状态。
package com.crazymo.moprint.util;
import android.text.TextUtils;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author : Crazy.Mo
*/
public class WifiPrinter {
private InputStream mInputStream;
/**
* 通过socket out 流往打印机发送数据
*/
private OutputStream mOutputStream;
private OutputStreamWriter mWriter;
private Socket mSocket;
private String mEncode;
private String mIp;
private int mPort;
public WifiPrinter(String ip, int port) {
mEncode = StandardCharsets.UTF_8.name();
this.mIp = ip;
this.mPort = port;
try {
if (mSocket != null) {
closeIOAndSocket();
} else {
mSocket = new Socket();
}
mSocket.connect(new InetSocketAddress(mIp, mPort));
mInputStream = mSocket.getInputStream();
mOutputStream = mSocket.getOutputStream();
mWriter = new OutputStreamWriter(mOutputStream, mEncode);
} catch (Exception e) {
Log.e("cmo", "构造WifiPrintHelper2对象" + e.toString());
}
}
/**
*
*/
public boolean isConnect() {
if (mSocket != null) {
if (!mSocket.isClosed() && mSocket.isConnected()) {
return true;
}
}
return false;
}
/**
* 关闭IO流和Socket
*/
public void closeIOAndSocket() {
try {
if (mInputStream != null) {
mInputStream.close();
}
if (mOutputStream != null) {
mOutputStream.close();
}
if (mWriter != null) {
mWriter.close();
}
if (mSocket != null) {
mSocket.close();
}
} catch (IOException e) {
Log.e("cmo", "关闭流异常");
}
}
/**
* 打印换行 和打印空白(一个Tab的位置,约4个汉字)
*
* @param lineNum
* @param tag 换行"\n" "\t"
* @return length 需要打印的空行数
* @throws IOException
*/
public void printLine(final int lineNum, final String tag) {
if (mWriter != null) {
try {
for (int i = 0; i < lineNum; i++) {
mWriter.write(tag);
}
mWriter.flush();
} catch (IOException e) {
Log.e("cmo", "打印空白字符异常:" + e.getMessage());
}
}
}
/**
* @param length 需要打印空白的长度,
* @throws IOException
*/
private void printTabSpace(int length) {
printLine(length, "\t");
}
/**
* 打印文字
*
* @param text
* @throws IOException
*/
public void printText(final String text) {
try {
byte[] content = text.getBytes(mEncode);
mOutputStream.write(content);
mOutputStream.flush();
} catch (IOException e) {
Log.e("cmo", "打印字符串异常:" + e.getMessage());
}
}
/**
* @param pdfPath 全路径
*/
public void printPDF(final String pdfPath) {
File pdf;
if (TextUtils.isEmpty(pdfPath)) {
return;
}
pdf = new File(pdfPath);
if (!pdf.exists()) {
return;
}
byte[] buf = new byte[1024];
int bytesRead;
FileInputStream input=null;
try {
input = new FileInputStream(pdfPath);
while ((bytesRead = input.read(buf)) > 0) {
mOutputStream.write(buf, 0, bytesRead);
}
mOutputStream.flush();
} catch (IOException e) {
Log.e("cmo", "打印图片异常:" + e.getMessage());
}try {
input.close();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 并不一定兼容打印二维码
* @param qrData 二维码的内容
* @throws IOException
*/
public void qrCode(final String qrData) {
int moduleSize = 8;
try {
int length = qrData.getBytes(mEncode).length;
//打印二维码矩阵
mWriter.write(0x1D);
mWriter.write("(k");
mWriter.write(length + 3);
mWriter.write(0);
mWriter.write(49);
mWriter.write(80);
mWriter.write(48);
mWriter.write(qrData);
mWriter.write(0x1D);
mWriter.write("(k");
mWriter.write(3);
mWriter.write(0);
mWriter.write(49);
mWriter.write(69);
mWriter.write(48);
mWriter.write(0x1D);
mWriter.write("(k");
mWriter.write(3);
mWriter.write(0);
mWriter.write(49);
mWriter.write(67);
mWriter.write(moduleSize);
mWriter.write(0x1D);
mWriter.write("(k");
mWriter.write(3);
mWriter.write(0);
mWriter.write(49);
mWriter.write(81);
mWriter.write(48);
mWriter.flush();
} catch (IOException e) {
Log.e("cmo", "打印二维码异常:" + e.getMessage());
}
}
/**
* 使用:CrazyThreadPool.THREAD_POOL_EXECUTOR.execute(new Runnable(){});
*/
public static class CrazyThreadPool {
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = (CPU_COUNT + 1);
private static final int KEEP_ALIVE = 1;
private static final int MAXIMUM_POOL_SIZE = ((CPU_COUNT * 2) + 1);
private static final BlockingQueue<Runnable> WORKQUEUE = new LinkedBlockingQueue<>(64);
private static final ThreadFactory THREADFACTORY = new ThreadFactory() {
private final AtomicInteger mCount = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "WifiPrint #" + this.mCount.getAndIncrement());
}
};
public static final Executor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE,
TimeUnit.SECONDS, WORKQUEUE, THREADFACTORY);
}
}
使用Sokect进行远程打印,此处需要注意一些细节,因为Sokect可以支持长连接,但是如果不处理好,当以下情况发生时Sokect会断开:
直接调用Socket类的close方法
Socket类的InputStream和OutputStream有一个关闭(必须是主动调用调用InputStream和OutputStream的 close方法关闭流),网络连接自动关闭
在程序退出时网络连接自动关闭
将Socket对象设为null或未关闭并使用new Socket()建立新对象后,由JVM的垃圾回收器回收为Socket对象分配的内存空间后自动关闭网络连接。
在使用Sokect 方法判断连接状态时,需要注意下两个方法isClosed方法和isConnected()方法:
isClosed方法——用来返回当前Sokect是否关闭,关闭则返回true。即不管Sokect对象是否曾经连接成功过,只要处于
关闭状态,isClosde就返回true。即使是建立一个未连接的Sokect对象,isClose也同样返回true
isConnected() ——用于返回sokect曾经是否成功连接过,而不是当前状态。isConnected方法所判断的并不是Sokect对象的当前连接状态,而是Sokect对象是否曾经连接成功过;如果成功连接过,即使现在isClosed返回true,isConnected仍然返回true。
因此要判断当前的Sokect对象是否处于连接状态,必须同时使用isClosed和isConnected方法,即只有当isClosed返回false,isConnected返回true的时候Sokect对象才处于连接状态,再次发送打印请求时可能会发生Sokect通信异常,所以我这里直接使用的是短连接的形式替代,每一次打印请求发送完毕之后就关闭此次的Sokect即对应的流。
WifiPrinter.CrazyThreadPool.THREAD_POOL_EXECUTOR.execute(new Runnable() {
@Override
public void run() {
WifiPrinter wifiPrintHelper= new WifiPrinter("192.168.0.101",9100);
wifiPrintHelper.printText("676810029020988879217932789789989879797978798178668");
wifiPrintHelper.printLine(1,"\n");
wifiPrintHelper.printText("android wifi print!");
////wifiPrintHelper.qrCode("hello world hahhahahahahahahahh");
wifiPrintHelper.closeIOAndSocket();
}
});
如果大家去运行,就会发现第三种方式虽然比较简单,可控性也比较强,但是对于格式来说就不好控制了,因为我们这里输入的都是原始的字节数据,目前对于Sokect打印方式,我采取的是先把原始的数据转为PDF,再把PDF的数据传入Sokect输出流中,无论是图片、布局、还是网页,都可以转为PDF再传入输出流。
WifiPrinter.CrazyThreadPool.THREAD_POOL_EXECUTOR.execute(new Runnable() {
@Override
public void run() {
WifiPrinter wifiPrintHelper = new WifiPrinter("192.168.0.101", 9100);
wifiPrintHelper.printPDF(Environment.getExternalStorageDirectory() + File.separator + "table3.pdf");
wifiPrintHelper.closeIOAndSocket();
}
});
如果需要精确优质的打印效果和排版,那么可能得查查阅对应打印机的打印指令,然后传入对应的指令字节数据,比如说惠普打印机的PLC等,这样以来就需要对不同品牌的打印指令进行适配,以上是个人浅见,仅供参考。
PS:源码传送门