java 开发安卓im_Android端IM应用中的@人功能实现:仿微博、QQ、微信,零入侵、高可扩展...
本文由“猫爸iYao”原创分享,感谢作者。
1、引言
最近有个需求:评论@人(没错,就是IM聊天或者微博APP里的@人功能),就像下图这样:
▲ 微信群聊界面里的@人功能
▲ QQ群聊界面里的@人功能
网上已经有一些文章分享了类似功能实现逻辑,但是几乎都是扩展EditText类,这种实现方式肯定不能进入我的首发阵容。你以为是因为它不符合面向对象六大原则?错,只因为它不够优雅!不够优雅!不够优雅!
那么,只有饮水机代码怎么办?当然是:
read the fuking source code
功夫不负有心人,我读了一遍EditText源码,然后就造出了这个“优雅的”轮子(开玩笑,EditText源码怎么能叫fuking source code,他有一个爸爸叫TextView)。废话不多说,上酸菜。
在此之前,你需要记住一个跟文本相关的思想:一切皆Span!
学习交流:
- 即时通讯/推送技术开发交流4群:101279154 [推荐]
2、添加标签文本样式,并与标签的业务数据绑定
所有人都知道文本样式与Spannable有关。
这里同样使用Spannable,我定义了一个DataBindingSpan接口,主要有两个功能:
1)让用户提供一个CharSequence对象作为标签,它决定了标签文本的样式和内容;
2)提供一个方法返回DataBindingSpan对象所绑定的业务数据。
interfaceDataBindingSpan {
fun spannedText(): CharSequence
fun bindingData(): T
}
示例代码:
class SpannableData(privateval spanned: String): DataBindingSpan {
override fun spannedText(): CharSequence {
return SpannableString(spanned).apply {
setSpan(ForegroundColorSpan(Color.RED), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
override fun bindingData(): String {
returnspanned
}
}
这个类仅仅包装了一个字符串,spannedText()返回一个改变标签文本颜色为红色的字符串,同时 bindingData()将该字符串作为业务数据返回。
你也可以把它换成其他的,user对象不错。spannedText()返回username,bindingData()返回userId,你就可以轻松实现@人功能业务数据绑定相关的逻辑了。
3、保证文本上绑定的数据的安全可靠
当我们把Span绑定到文本上以后,我们需要在文本发生变化时,保证文本和数据的安全性,可靠性,一致性。
其实从DataBindingSpan开始,我们就在处理这个事情了。正如SpannableData所展现的一样,当spannedText()返回的是一个Spannable对象时,使用Spanned.SPAN_EXCLUSIVE_EXCLUSIVE作为flag。它不能在头部和尾部扩展Span的范围,只允许中间插入。同时,当Span覆盖的文本被删除时,Span也会被删除。也就是说,它天生具有一定数据安全可靠的属性。这会为我们省掉很多事情。
当然,Spanned.SPAN_EXCLUSIVE_EXCLUSIVE并不具备完全的安全性。毕竟它不能阻止中间插入。这个事情得我们自己来做。那么,为了禁止中间插入,我们应该怎么做呢?
这个需求又产生了两个问题:
1)当普通文本发生变化后,如何监控一个Span起始位置发生变化?
2)如何禁止Span内部插入光标?
对于第一个问题,我在网上看到过一种思路。维护一个Span起始位置管理器SpanRangeManager,然后利用TextWather监听文本变化,文本的任何变化都会导致SpanRangeManager重新测算Span的位置。
当然,如果我使用这种方式,就不会有这篇博客了。其实Android SDK便有一个优秀的Span管理器,那就是SpannableStringBuilder。同时SDK提供了一个侦听器SpanWatcher侦听SpannableStringBuilder中Span的变化。有兴趣的同学可以去看一看他的源码。
第二个问题,我们要保证文本与数据的一致性,禁止光标插入到Span覆盖的文本中间。
有三种做法:
1)普通文本,当标签文本被破坏(删除、插入、追加文本)时,让绑定的数据失效,这就是微信的做法;
2)普通文本,把标签文本作为一个整体,不能对标签内部插入光标,杜绝数据被破坏的情况,这是微博的做法;
3)占位符,使用不可分割的Span(如ImageSpan)替换,这是QQ的做法。
微博、微信的方法都必须要对软键盘删除键、文本变化、光标活动、文本选中状态以及span变化进行监听和处理。QQ就简单多了,后面会讲到。
4、微博的做法
4.1 侦听并处理光标活动、选中状态以及Span位置变化
对于光标活动和选中状态侦听,如果采用继承EditText的方式实现标签文本功能,重写onSelectionChanged(int selStart, int selEnd)方法便能够侦听光标活动。但是,这种方式怎么能算优雅呢?
要想“优雅地”实现怎么办?还是那句话:
read the fuking source code
两个角色:
Selection
SpanWatcher
如果有一篇文章叫做《Selection如何管理文本光标活动和选中状态?》,那么它一定能回答这个问题。
这里不会详细讲述Selection内部实现,你只需要知道两点:
1)选中状态具有起点(start)和终点(end),而start与end反映在文本中,其实是两个NoCopySpan: START, END;
2)光标是一种特殊的选中状态,start与end在同一位置。
既然选中状态的实现是Span,它就是与View无关的,而与Spannable有关。也就是说,我们可以不使用EditText自身的API却能够管理它的光标活动和选中状态(请注意这几句话,他是“优雅实现”的基石)。
Selection管理光标活动。那么,SpanWatcher又是什么?前面说了,它是SpannableStringBuidler中用于侦听Span变化的监听器。有个东西和它很像,TextWatcher。没错,他俩有同一个爹NoCopySpan。他俩一个侦听文本变化,一个侦听Span变化。
下面是SpanWatcher的源码:
/**
* When an object of this type is attached to a Spannable, its methods
* will be called to notify it that other markup objects have been
* added, changed, or removed.
*/
public interface SpanWatcher extendsNoCopySpan {
/**
* This method is called to notify you that the specified object
* has been attached to the specified range of the text.
*/
public void onSpanAdded(Spannable text, Object what, intstart, intend);
/**
* This method is called to notify you that the specified object
* has been detached from the specified range of the text.
*/
public void onSpanRemoved(Spannable text, Object what, intstart, intend);
/**
* This method is called to notify you that the specified object
* has been relocated from the range ostart…oend
* to the new range nstart…nend
of the text.
*/
public void onSpanChanged(Spannable text, Object what, intostart, intoend, intnstart, intnend);
}
我们已经知道光标是一种Span。也就是说,我们可以通过SpanWatcher侦听光标活动,通过Selection实现当光标移动到Span内部时,让它重新移动到Span最近的边缘位置,Span内部永远无法插入光标。这样便能够实现把标签文本(spanned text)看作一个整体的思路。
下面是代码实现:
package com.iyao
import android.text.Selection
import android.text.SpanWatcher
import android.text.Spannable
import kotlin.math.abs
import kotlin.reflect.KClass
class SelectionSpanWatcher(privateval kClass: KClass): SpanWatcher {
privatevar selStart = 0
privatevar selEnd = 0
override fun onSpanChanged(text: Spannable, what: Any, ostart: Int, oend: Int, nstart: Int, nend: Int) {
if(what === Selection.SELECTION_END && selEnd != nstart) {
selEnd = nstart
text.getSpans(nstart, nend, kClass.java).firstOrNull()?.run {
val spanStart = text.getSpanStart(this)
val spanEnd = text.getSpanEnd(this)
val index = if(abs(selEnd - spanEnd) > abs(selEnd - spanStart)) spanStart elsespanEnd
Selection.setSelection(text, Selection.getSelectionStart(text), index)
}
}
if(what === Selection.SELECTION_START && selStart != nstart) {
selStart = nstart
text.getSpans(nstart, nend, kClass.java).firstOrNull()?.run {
val spanStart = text.getSpanStart(this)
val spanEnd = text.getSpanEnd(this)
val index = if(abs(selStart - spanEnd) > abs(selStart - spanStart)) spanStart elsespanEnd
Selection.setSelection(text, index, Selection.getSelectionEnd(text))
}
}
}
override fun onSpanRemoved(text: Spannable?, what: Any?, start: Int, end: Int) {
}
override fun onSpanAdded(text: Spannable?, what: Any?, start: Int, end: Int) {
}
}
现在,我们只需要在setText()之前把这个Span添加到文本上就可以了。
4.2 侦听软键盘删除键并处理选中状态
现在已经把Span覆盖的文本作为一个整体,且无法插入光标,但是当我们从Span尾部删除文本,仍是逐字删除。我们的要求是删除Span文本时,能够整体删除整个Span,这就需要监听键盘删除键。
package com.iyao
import android.text.Selection
import android.text.Spannable
class KeyCodeDeleteHelper private constructor(){
companion object {
fun onDelDown(text: Spannable): Boolean {
val selectionStart = Selection.getSelectionStart(text)
val selectionEnd = Selection.getSelectionEnd(text)
text.getSpans(selectionStart, selectionEnd, DataBindingSpan::class.java).firstOrNull { text.getSpanEnd(it) == selectionStart }?.run {
return(selectionStart == selectionEnd).also {
val spanStart = text.getSpanStart(this)
val spanEnd = text.getSpanEnd(this)
Selection.setSelection(text, spanStart, spanEnd)
}
}
returnfalse
}
}
}
让我们使用它:
editText.setOnKeyListener { v, keyCode, event ->
if(keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) {
return @setOnKeyListenerKeyCodeDeleteHelper.onDelDown((v as EditText).text)
}
return @setOnKeyListenerfalse
}
//取数据
val strings = editText.text.let {
it.getSpans(0, it.length, DataBindingSpan::class.java)
}.map { it.bindingData() }
现在就可以实现微博一样效果了。一切都那么顺利。
然而,当你运行起来会发现,SelectionSpanWatcher完全没有效果。轮子都造好了,你告诉我轴承断了。
并且,当你打印EditText文本上的Span时,你找不到SelectionSpanWatcher。这说明SelectionSpanWatcher在setText()过程中被清除掉了。那我们能不能把它放在setText()之后设置呢?如果你这么做,你会发现一个新问题。setText()添加的文本没有效果。似乎我们不能通过setText()添加内容,只能使用getText()追加内容。不仅如此,我们必须完全禁用setText(),因为每一次调用,都会清除掉SelectionSpanWatcher。
这种方式看起来还不错,但是换一个不熟悉这个特性的人来使用怎么办?告诉他不能用setText()方法?或者用内联方法或继承的方式为EditText新增一个方法? 这些都可以,唯一的缺点是,它不是我想要的优雅。我要让它就像使用普通EditText一样正常使用setText()方法。
需要思考的问题是,SelectionSpanWatcher在哪里消失了?我要重新找回这个轴承。
4.3 让轮子优雅实现的轴承:Editable.Factory
SelectionSpanWatcher在setText()方法中消失了。我需要去阅读它的源码。
EditText重写了getText()、setText(CharSequence text, BufferType type)方法:
@Override
public Editable getText() {
CharSequence text = super.getText();
// This can only happen during construction.
if(text == null) {
returnnull;
}
if(text instanceofEditable) {
return(Editable) super.getText();
}
super.setText(text, BufferType.EDITABLE);
return(Editable) super.getText();
}
@Override
public voidsetText(CharSequence text, BufferType type) {
super.setText(text, BufferType.EDITABLE);
}
从源码上看,重写的唯一目的是将BufferType设置为BufferType.EDITABLE。
我们都知道TextView有三种文本模式:
1)BufferType.NORMAL 静态文本模式,这种模式的文本无法编辑,也没有富文本样式;
2)BufferType.SPANNABLE 带文本样式的模式,不可编辑。当TextView.isTextSelectable()返回true时,TextView的文本模式;
3)BufferType.EDITABLE EditText的文本模式,可编辑,带文本样式。
这里不具体讲这三种模式相关的内容。只需要知道EditText的模式是BufferType.EDITABLE。
那么,BufferType.EDITABLE与“轴承”又有什么关系呢? 确实有关系。
阅读上面的源码片段时,不知道有没有人注意到setText(CharSequence)传入一个CharSequence对象,TextView#getText()返回的是CharSequence对象, EditText#getText()却返回一个Editable对象。它是在什么时候,如何完成的转换呢?它会不会是一个突破口?
从Editable getText()源码看,它是在super.setText(text, BufferType.EDITABLE)中完成转换的。
在TextView源码中,setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen)有这样一个流程分支:
private voidsetText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) {
if(type == BufferType.EDITABLE || getKeyListener() != null|| needEditableForNotification) {
...
Editable t = mEditableFactory.newEditable(text);
text = t;
...
}
...
mBufferType = type;
setTextInternal(text);
...
}
由此可见,我们赋值给EditText的CharSequence对象先经过mEditableFactory转换为Editable对象,最终被真正赋值给EditText,mEditableFactory的类型正是Editable.Factory,这是一个静态内部类。
我们看看Editable.Factory的具体实现是什么:
/**
* Factory used by TextView to create new {@link Editable Editables}. You can subclass
* it to provide something other than {@link SpannableStringBuilder}.
*
* @see android.widget.TextView#setEditableFactory(Factory)
*/
public static class Factory {
private static Editable.Factory sInstance = newEditable.Factory();
/**
* Returns the standard Editable Factory.
*/
public static Editable.Factory getInstance() {
returnsInstance;
}
/**
* Returns a new SpannedStringBuilder from the specified
* CharSequence. You can override this to provide
* a different kind of Spanned.
*/
public Editable newEditable(CharSequence source) {
return new SpannableStringBuilder(source);
}
}
很简单的转换,它将CharSequence对象转换为Editable的子类SpannableStringBuilder的对象。
我们看一看这个构造器:
public SpannableStringBuilder(CharSequence text, intstart, intend) {
...
mText = ArrayUtils.newUnpaddedCharArray(GrowingArrayUtils.growSize(srclen));
...
if(text instanceofSpanned) {
Spanned sp = (Spanned) text;
Object[] spans = sp.getSpans(start, end, Object.class);
for(intii = 0; ii < spans.length; ii++) {
if(spans[ii] instanceofNoCopySpan) {
continue;
}
...
setSpan(false, spans[ii], st, en, fl, false);
}
restoreInvariants();
}
}
这就是轴承断掉的原因所在。
前面提到SpanWatcher继承自NoCopySpan,而NoCopySpan是一个标记接口。它的作用就是标记一个Span无法被拷贝。SpannableStringBuilder在构造的时候,会忽略掉所有NoCopySpan及其子类。因此,SelectionSpanWatcher没有被赋值给EditText的文本。
既然NoCopySpan不被复制,那我们等SpannableStringBuilder构造好后重新设置便好了。Editable.Factory的注释让我看到了希望。他可以被重写,并被重新注入EditText。
android.widget.TextView#setEditableFactory(Factory)
下面是重写的Editable.Factory,作用是重新把NoCopySpan设置到SpannableStringBuilder上:
package com.iyao
import android.text.Editable
import android.text.NoCopySpan
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.BackgroundColorSpan
class NoCopySpanEditableFactory(private var arg val spans: NoCopySpan): Editable.Factory() {
override fun newEditable(source: CharSequence): Editable {
return SpannableStringBuilder.valueOf(source).apply {
spans.forEach {
setSpan(it, 0, source.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}
}
}
}
没错,算空行一共17行代码。它就是这个轮子的新轴承。现在我们重新使用它。
通过editText.setEditableFactory()换上新的轴承,让轮子跑起来:
editText.setEditableFactory(NoCopySpanEditableFactory(SelectionSpanWatcher(DataBindingSpan::class)))
editText.setOnKeyListener { v, keyCode, event ->
if(keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) {
return @setOnKeyListenerKeyCodeDeleteHelper.onDelDown((v as EditText).text)
}
return @setOnKeyListenerfalse
}
一个“优雅的”实现诞生了,你可以像微博一样在评论中使用@人了。
运行效果:
5、微信的做法
微信的处理方式要简单一些,他们不禁止在Span覆盖的文本中插入光标,而是当Span覆盖的文本改变后清除Span以及数据。他们同样要监听删除键实现Span整体删除,只是表现上与微博稍有区别。
微信的三部曲。
首先,定义一个接口用来判断Span是否失效:
package com.iyao
import android.text.Spannable
interface RemoveOnDirtySpan {
fun isDirty(text: Spannable): Boolean
}
其次,让SpannableData实现此接口。当然,你也可以让RemoveOnDirtySpan继承DataBindingSpan,尽管我觉得这样不符合“六大”。
class SpannableData(privateval spanned: String): DataBindingSpan, RemoveOnDirtySpan {
override fun spannedText(): CharSequence {
return SpannableString(spanned).apply {
setSpan(ForegroundColorSpan(Color.RED), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
override fun bindingData(): String {
return spanned
}
override fun isDirty(text: Spannable): Boolean {
val spanStart = text.getSpanStart(this)
val spanEnd = text.getSpanEnd(this)
return spanStart >= 0&& spanEnd >= 0&& text.substring(spanStart, spanEnd) != spanned
}
}
最后,重新写一个DirtySpanWatcher用来删除失效的Span:
package com.iyao
import android.text.SpanWatcher
import android.text.Spannable
class DirtySpanWatcher(private val removePredicate: (Any) -> Boolean) : SpanWatcher {
override fun onSpanChanged(text: Spannable, what: Any, ostart: Int, oend: Int, nstart: Int,
nend: Int) {
if(what is RemoveOnDirtySpan && what.isDirty(text)) {
val spanStart = text.getSpanStart(what)
val spanEnd = text.getSpanEnd(what)
text.getSpans(spanStart, spanEnd, Any::class.java).filter {
removePredicate.invoke(it)
}.forEach {
text.removeSpan(it)
}
}
}
override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) {
}
override fun onSpanAdded(text: Spannable, what: Any, start: Int, end: Int) {
}
}
现在,我们让微信也跑起来:
editText.setEditableFactory(NoCopySpanEditableFactory(DirtySpanWatcher{
it is ForegroundColorSpan || it is RemoveOnDirtySpan
}))
editText.setOnKeyListener { v, keyCode, event ->
if(keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) {
KeyCodeDeleteHelper.onDelDown((v as EditText).text)
}
return @setOnKeyListenerfalse
}
需要注意,微信和微博有一点小区别,微博有二次确认删除选中,微信没有。代码上的差别仅仅是微信少了一个return@setOnKeyListener。
运行效果:
6、QQ的做法
QQ的做法太简单,我不太想讲它。这里写一个简单的Demo演示一下。
QQ同样需要用到DataBindingSpan,甚至你也可以不用。它的核心是ImageSpan:
class SpannableData(privateval spanned: String): DataBindingSpan {
override fun spannedText(): CharSequence {
returnSpannableString("@$spanned ").apply {
setSpan(ImageSpan(LabelDrawable("@$spanned", color = Color.LTGRAY), spanned), 0, length-1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
override fun bindingData(): String {
return spanned
}
}
现在只需要实现一个绘制文字的Drawable,这里我取名叫LabelDrawable,也许并不准确:
class LabelDrawable(val text: CharSequence, private val textPaint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 42f
this.color = Color.DKGRAY
textAlign = Paint.Align.CENTER
}, color: Int): ColorDrawable(color) {
init {
calculateBounds()
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
canvas.drawText(text, 0, text.length, bounds.centerX().toFloat(), bounds.centerY().toFloat() + getBaselineOffset(textPaint.fontMetrics), textPaint)
}
private fun calculateBounds() {
textPaint.getTextBounds(text.toString(), 0, text.length, bounds)
bounds.inset(-8, -4)
bounds.offset(8, 0)
}
private fun getBaselineOffset(fontMetrics: Paint.FontMetrics): Float {
return (fontMetrics.descent - fontMetrics.ascent) / 2- fontMetrics.descent
}
}
就像普通的Span一样使用他就行了。
运行效果:
如果想要做的更好一点,你需要处理多行文本measure、layout、draw等问题。给个小提示,TextView截屏也是一个Drawable。如果有一个View,即使它并未attach到Window上,我们也可以手动调用measure()、layout()、draw()方法获取一个View的截图Drawable用来添加到ImageSpan中使用,不过这样无法响应触摸事件。
7、获取文本中绑定的数据
用下面的代码就行了:
val strings = editText.text.let {
it.getSpans(0, it.length, DataBindingSpan::class.java)
}.map { it.bindingData() }
8、本文源码附件下载
9、题外话:本文代码是Kotlin写的,但我想要Java版的@人实现,怎么办?
是的,Kotlin暂时还没这么广泛的使用,用不了。
但,@这个看似很简都的功能,实际上要不出bug的做好,还是有点难度,或者说代码量还不算小。
那么,哪里能找到靠谱的@人功能的Java版实现?
答案在这里:可以下载网易云信官方开源的IM Demo,里面就有@功能完整代码实现:
▲ @人功能完整源码位置
别跟我说这是违法的,他们自已说是开源。。。
好了,我没有收网易云信任何好处费,之所以推荐你去“扒”它的源码,是因为我评估了主流的第3方IM开源的Demo代码后,@人功能写的还算不错的,就只有网易云信了,木有办法。
附录:更多精品资源下载
java 开发安卓im_Android端IM应用中的@人功能实现:仿微博、QQ、微信,零入侵、高可扩展...相关推荐
- Android端IM应用中的@人功能实现:仿微博、QQ、微信,零入侵、高可扩展
本文由"猫爸iYao"原创分享,感谢作者. 1.引言 最近有个需求:评论@人(没错,就是IM聊天或者微博APP里的@人功能),就像下图这样: ▲ 微信群聊界面里的@人功能 ▲ Q ...
- android 仿qq 通讯录,Android端IM应用中的@人功能实现:仿微博、QQ、微信,零入侵、高可扩展[图文+源码]...
本文由"猫爸iYao"原创分享,感谢作者. 1.引言 最近有个需求:评论@人(没错,就是IM聊天或者微博APP里的@人功能),就像下图这样: WechatIMG43.jpg (19 ...
- Java开发人员应该知道的5大Spring Boot功能
您可能已经听说过Spring Boot,这是用不到140个字符创建一个Spring Web应用程序的神奇力量,可以在一条推文中编写这些字符,但这到底意味着什么? 哪些功能可以使Spring Boot具 ...
- SpringBoot高并发!java开发安卓app电子书
前言 我想,很多人和我一样在煎熬中度过着2021年,也经历了不少困难,随着国家对疫情的控制,互联网行业又重新迎来了生机. 我在2021年拿到了阿里Java研发岗的offer,也算是正式提桶进大厂的打工 ...
- Java实现安卓连接商米POS收银机打印小票功能
在收银系统中经常使用到打印小票的功能.本文将Java如何实现商米POS收银机打印小票的功能.包括""定义管理打印相关方法的类,封装好方法供外部调用"."调用打印 ...
- php h5支付没有返回app中,很多人都不知道的监听微信、支付宝等移动app及浏览器的返回、后退、上一页按钮的事件方法...
在实际的应用中,我们常常需要实现在移动app和浏览器中点击返回.后退.上一页等按钮实现自己的关闭页面.调整到指定页面或执行一些其它操作的 需求,那在代码中怎样监听当点击微信.支付宝.百度糯米.百度钱包 ...
- java开发安卓app!已成功拿下字节、腾讯、脉脉offer,系列篇
开头 昨天去面了一家公司,价值观有受到冲击. 面试官技术方面没的说,他可能是个完美主义的人,无论什么事情到了他那里好像都有解决的方案,我被说的无所适从,感觉他很厉害. 但我不能认可的是,面试官觉得加班 ...
- 一文详解:java开发安卓应用
JAVA基础 JAVA异常分类及处理 异常分类 异常的处理方式 Throw和throws的区别 JAVA反射 动态语言 反射机制概念 (运行状态中知道类所有的属性和方法) Java反射API 反射使用 ...
- Xamarin和Java开发安卓_将原生移动开发与Xamarin相结合
假设我们已经为不同的平台(iOS / Android / Winfon(未来))实现了移动应用程序 . 所有应用都有一些共同的业务逻辑 . 例如,计算器应用程序:用户输入两位数,我们的应用程序应该能够 ...
最新文章
- kafka0.9 java commit_kafka提交offset失败
- Linux单网卡多个IP(或者多个网卡多个IP)设置
- 深入浅出,Spring 框架和 Spring Boot 的故事
- pycharm解决Inconsistent indentation:mix of tabs and spaces
- Tomcat内存设置详解
- java输入流的控制_Java-Android-IO流-控制台输入输出
- 银行营业网点管理系统——entity类(Branches)
- 太突然!一日本上班族大叔被通知得了诺贝尔奖,他却选择消失了16年,又有重大发现!...
- 在linux怎样删除文件夹里,linux删除文件夹(里头有文件)
- 机器学习爬大树之(GBDT原理)--回归篇
- Maven--部署构件至 Nexus
- Atitit nlp重要节点 v3 目录 1. 语法分析重点 节点余额365个 1 2. nlp词性表 2 2.1. 词语分类13类 2 2.2. 副词 约20个 3 2.3. 代词30个 3 2
- ps盖印图层在哪里_PS盖印图层快捷键
- java模拟新浪微博用户注册
- DSP 2812 的经典资料
- 如何使用Python解锁星河远征军的科幻旅途
- 计算机组装与维护考试题a卷,计算机组装与维护考试题A卷.docx
- photoshop给照片去斑的一些办法
- win7防火墙入站规则 出站规则 什么意思
- C#运控框架 雷赛运动控制 DMC系列 运动控制项目 C#源码