ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java4Android包与访问权限详解:从编译报错到工程实践

Java4Android包与访问权限详解:从编译报错到工程实践 1. 从一次编译报错说起为什么包和访问权限绕不开刚上手Java4Android那会儿我写了个小工具类里面几个方法随手标了默认权限心想反正都在一个工程里调用应该没问题。结果Android Studio直接甩给我一屏红色报错大意是“方法不可见”。当时我第一反应是都在同一个项目里凭什么不让我用后来才明白Java的访问权限不是按“项目”划分的而是按“包”划分的。这个认知上的偏差几乎每个从脚本语言或者C语言转过来的开发者都会踩一次。包和访问权限这两个东西在Java4Android的学习路径里属于那种“看起来简单、用起来处处是坑”的知识点。它们不像Activity生命周期或者RecyclerView那样有直观的视觉效果但一旦理解不到位代码组织会越来越乱模块之间的耦合会越来越紧最后整个工程变成一锅粥。更现实的问题是面试的时候面试官特别喜欢拿访问权限修饰符来考察你对Java语言设计思想的理解因为这块内容能直接反映出一个开发者有没有“封装”的意识。这篇文章适合正在走Java4Android自学路线的朋友也适合那些已经能写Android界面但对自己代码结构不太满意的开发者。我会从包的本质讲起把四种访问权限的边界掰开揉碎然后落到Android工程里包的实际组织方式最后分享几个我在实际项目中踩过的坑和总结出来的排查技巧。整篇内容会围绕“包和访问权限”这个核心展开不会跑偏到其他Java语法上去。2. 包到底是什么不只是文件夹那么简单2.1 包的本质与命名规范很多人第一次接触包的时候会把它理解成“文件夹”这个类比不能说错但不够准确。包在Java里承担了三个职责第一是命名空间的管理第二是访问权限的边界划分第三是类的组织与查找。你可以把包想象成一栋楼里的不同房间房间号就是包名同一个房间里的东西互相拿起来方便跨房间拿东西就得看门有没有锁。包名的命名规范在Java4Android的学习阶段就值得养成好习惯。标准做法是全部小写用域名倒序作为前缀。比如某个开发者所在的团队有一个内部项目代号叫“bluefin”那包名可能是com.bluefin.android.utils这种形式。倒序域名的目的是保证全球唯一性虽然我们在学习阶段写的代码不会发布到公共仓库但从一开始就按规范来后面切换到团队协作时不需要重新适应。在Android工程里包名还有一个额外的身份它就是应用的唯一标识。你在build.gradle里配置的applicationId通常和根包名一致这个标识决定了应用在设备上的身份。所以包名一旦确定后期修改的成本非常高涉及签名、渠道、数据迁移等一系列问题。我的建议是在项目创建的第一天就把包名想清楚不要用com.example.xxx这种默认值哪怕是个练习项目也认真起个名字。2.2 package语句与目录结构的对应关系Java要求package语句声明的包名必须和源文件在文件系统中的目录结构完全对应。比如你声明了package com.bluefin.utils;那么这个.java文件必须放在com/bluefin/utils/这个目录路径下。这个规则在命令行编译的时候是硬性的不满足就报错。Android Studio会自动帮你维护这个对应关系你在IDE里创建包的时候它会同时创建目录但理解这个对应关系仍然重要因为当你手动拷贝文件或者用命令行工具处理代码时目录结构不对就会出问题。这里有一个新手经常遇到的场景从网上拷贝了一个工具类文件直接拖进Android Studio的某个包目录下结果文件开头的package声明和实际所在目录不一致编译器立刻报错。解决办法要么改package声明要么把文件移到匹配的目录下。我个人的习惯是改package声明因为移动文件有时候会触发IDE的索引重建等起来比较烦。还有一个细节值得注意包名和目录结构在编译后会被保留在.class文件中运行时JVM会根据包名去对应的目录路径加载类。这也是为什么混淆代码的时候包名结构会影响混淆结果的映射关系。虽然这是比较进阶的话题但在学习阶段知道“包名不只是给人看的机器也依赖它”这件事对理解整个体系有帮助。2.3 import语句的工作机制import语句的作用是告诉编译器“我接下来要用到的某个类在哪个包里”这样你在代码里就可以用类的简单名称而不是全限定名。比如你写了import java.util.ArrayList;后面就可以直接写ArrayList不用每次都写java.util.ArrayList。但import有一个容易误解的地方它并不会把那个包里的所有类都“导入”进来也不会影响访问权限。import纯粹是编译期的语法糖帮你省去写全限定名的麻烦。即使你写了import com.bluefin.utils.*;也只是导入了这个包下的所有类名让编译器知道这些类的存在但你能不能访问某个类的某个方法仍然取决于那个方法本身的访问权限修饰符。另外import不能跨包导入默认权限的类。如果一个类是默认权限也就是没有修饰符那么即使你import了它所在的包在其他包里也无法使用这个类。这一点在后面讲访问权限的时候会详细展开。3. 四种访问权限从最严到最松的完整拆解3.1 private只给自己人看private是最严格的访问级别被它修饰的成员只能在本类内部访问。这里的“本类”包括类的定义体内部包括内部类但不包括同一个包里的其他类也不包括子类。在实际开发中private最常见的用法是修饰成员变量配合getter和setter方法对外提供受控的访问。比如一个用户信息类里面的密码字段应该是private的外部只能通过特定的方法去验证密码而不能直接读取。这样做的好处是将来如果密码的存储方式从明文改成哈希值只需要改类内部的实现外部调用代码完全不受影响。但private也不是用得越多越好。我见过一些开发者把所有方法都标成private结果单元测试没法写因为测试类访问不到这些方法。这种情况下通常的做法是把需要测试的方法改成包级私有默认权限然后把测试类放在同一个包下。这个技巧在Android的单元测试里很常用因为测试代码和被测代码经常不在同一个源集里但可以通过配置让它们在编译后处于同一个包下。还有一个关于private的细节构造方法也可以是private的。这通常用于单例模式或者工具类防止外部直接new出对象。比如一个只提供静态方法的工具类把构造方法设为private可以避免被实例化这是一种约定俗成的写法。3.2 默认权限包级私有同一个包里的默契不写任何访问修饰符的时候就是默认权限也叫包级私有。这种权限的边界是“同一个包内可见”。也就是说同一个包下的所有类都可以访问这个成员但包外的类不行即使是子类也不行。默认权限在Android开发里有一个非常典型的应用场景把一些辅助类或者内部实现类放在同一个包下只让这个包里的其他类使用不对外暴露。比如你写了一个网络请求库里面有一个HttpEngine类负责底层的连接管理这个类不需要让外部知道只需要让同包下的HttpClient类调用就行。这时候把HttpEngine设为默认权限既保证了功能可用又避免了外部误用。但默认权限也有它的局限性。最大的问题是当你的项目规模变大包的数量增多时包与包之间的依赖关系会变得复杂。如果两个包之间需要共享一些类而这些类又是默认权限那就只能把它们移到同一个包里或者改成public。前者会导致包越来越大后者会破坏封装。所以默认权限适合用在“包内协作”的场景不适合用来做跨包的模块化设计。3.3 protected给子类留一扇门protected的可见范围比默认权限多了一个维度除了同一个包内的类可以访问不同包下的子类也可以访问。这个设计是为了支持继承体系下的代码复用。理解protected的关键在于搞清楚“子类可以访问”的具体含义。假设有一个父类A在包p1里定义了一个protected方法foo()。子类B在包p2里继承了A。那么在B的代码内部可以通过this.foo()或者super.foo()来调用这个方法。但是如果B里面有一个A类型的引用变量a通过a.foo()来调用是不行的因为a的编译时类型是A而A在p1包外foo()对p1包外的非子类实例不可见。这个规则初看有点绕但它的设计意图是合理的protected是为了让子类能够复用父类的实现细节而不是让子类随意操作其他父类实例的内部状态。我在学习阶段曾经因为这个问题卡了很久后来画了一张表才理清楚。访问位置private默认protectedpublic本类内部可见可见可见可见同包其他类不可见可见可见可见不同包子类通过this/super不可见不可见可见可见不同包子类通过父类引用不可见不可见不可见可见不同包非子类不可见不可见不可见可见这张表建议每个学Java4Android的人都自己默写一遍默写出来了访问权限这块基本就不会再糊涂了。3.4 public完全开放public是最宽松的访问级别任何地方都可以访问。在Android开发里public通常用在对外暴露的API上比如一个工具类的静态方法、一个自定义View的公开属性、一个数据模型的getter方法等。但public用多了会带来两个问题。第一是API的稳定性压力一旦一个方法被声明为public就意味着你承诺了这个方法是稳定的将来修改它的签名或者行为会影响所有调用方。第二是安全性和封装性的削弱外部代码可以随意调用和修改你的内部状态类的行为变得不可控。我的经验是在写一个新类的时候先把所有成员都设为private然后根据实际需要逐步放开权限。需要给子类用的改成protected需要给同包用的保持默认确实需要对外暴露的才改成public。这种“从紧到松”的策略比“从松到紧”要安全得多因为后者很容易遗漏一些本该收紧的成员。4. Android工程里的包组织实践4.1 按层分包与按功能分包Android工程里怎么组织包是一个没有标准答案但影响深远的问题。常见的做法有两种按层分包和按功能分包。按层分包是把所有Activity放在ui包下所有数据模型放在model包下所有网络请求放在network包下所有工具类放在util包下。这种方式的优点是结构清晰一眼就能看出项目里有哪些类型的组件。缺点是当项目变大时一个功能模块的代码会散落在多个包里修改一个功能需要来回跳转。按功能分包是把一个功能相关的所有类放在同一个包下比如login包里包含LoginActivity、LoginPresenter、LoginModel、LoginApi等。这种方式的优点是功能内聚修改一个功能只需要在一个包里操作。缺点是包的数量会比较多而且有些跨功能的类不好归类。我个人的做法是混合使用顶层按功能分包每个功能包内部再按层分。比如feature.login下面有ui、data、domain三个子包。这样既保证了功能的内聚性又保持了层级的清晰。当然这种结构在小型项目里可能显得过度设计但对于中大型项目来说后期维护的成本会低很多。4.2 包与模块化从package到module在Android Studio里除了包package之外还有一个更粗粒度的组织单位叫模块module。一个项目可以包含多个module每个module可以有自己的包结构。module之间的依赖关系需要在build.gradle里显式声明而包之间的依赖关系是隐式的只要在同一个module里任何包都可以引用其他包里的public类。这个区别很重要。如果你希望两个功能模块之间有清晰的边界应该把它们拆成不同的module而不是仅仅放在不同的包里。因为包级别的访问权限只能控制到“默认权限”这个粒度而module级别的隔离可以做到“不依赖就完全不可见”。在实际项目中我通常会把核心工具类、网络库、数据库层拆成独立的module业务功能则根据团队规模决定是否拆分。但拆module也有代价编译速度会变慢因为每个module都需要单独编译依赖管理会变复杂需要处理版本冲突代码导航会变麻烦因为跨module的跳转不如同module内流畅。所以拆不拆、怎么拆需要根据项目规模和团队情况来权衡。4.3 包命名的一致性维护随着项目迭代包名很容易变得混乱。今天加一个utils包明天加一个helpers包后天又加一个common包最后谁也说不清哪个工具类在哪个包里。这种混乱在多人协作的项目里尤其常见。我的做法是在项目初期就定好一套包命名规则并且写进团队的开发规范里。比如工具类统一放在util包下不要用utils、helpers、common等变体数据模型统一放在model包下不要用bean、entity、dto等混用接口定义统一放在api包下不要用service、remote等混用。规则定好之后代码审查的时候要严格执行发现不一致就要求修改。另外Android Studio提供了一个“Move”重构功能可以安全地移动类到不同的包下并自动更新所有引用。这个功能比手动改package声明和import语句要可靠得多建议养成用重构而不是手动修改的习惯。5. 实操中踩过的坑与排查技巧5.1 编译期报错“不可见”的排查思路当你看到“xxx is not public in xxx; cannot be accessed from outside package”这类报错时排查思路可以按以下顺序走。第一步确认被访问的类或成员的访问修饰符是什么。如果是private那除了本类谁都访问不了需要改成更宽松的权限或者通过公开方法间接访问。如果是默认权限检查调用方和被调用方是否在同一个包下。如果是protected检查调用方是否是子类以及调用方式是否是通过this或super。第二步确认包名是否一致。有时候两个类的包名看起来一样但因为大小写或者拼写差异实际上不在同一个包下。比如com.bluefin.util和com.bluefin.utils是两个不同的包前者里的默认权限成员对后者不可见。第三步检查是否有多个同名类。这种情况在引入第三方库时比较常见比如你自己写了一个StringUtils第三方库里也有一个StringUtilsimport的时候导错了导致访问权限对不上。解决办法是用全限定名或者给其中一个改个名字。5.2 反射绕过访问权限的注意事项Java的反射机制可以绕过访问权限检查通过setAccessible(true)来访问private成员。这个能力在某些场景下很有用比如单元测试、序列化框架、依赖注入框架等。但在Android开发里反射绕过访问权限有几个需要注意的地方。首先是性能问题。反射调用比直接调用慢很多在频繁调用的路径上使用反射会导致明显的性能下降。其次是兼容性问题。Android的不同版本对反射的限制不同某些系统类或者被标记为hide的API在不同版本上的反射行为可能不一致。最后是安全问题。绕过访问权限意味着破坏了封装如果被绕过的代码在后续版本中修改了内部实现反射调用方可能会在运行时崩溃。我的建议是除非没有其他办法否则不要用反射去访问private成员。如果确实需要把反射相关的代码集中在一个地方加上充分的注释和异常处理并且写单元测试覆盖。5.3 常见问题速查表问题现象可能原因解决方向编译报错“cannot be accessed from outside package”访问了其他包的默认权限成员改访问修饰符或移到同包子类无法调用父类的protected方法通过父类引用调用而非this/super改用this或super调用import后仍然找不到类类本身是默认权限把类改成public两个包下的同名类冲突import了错误的类使用全限定名或重命名单元测试无法访问private方法测试类不在同一个包下把测试类放到同包或改用默认权限模块间引用报错module依赖未声明在build.gradle里添加依赖这张表里的问题我在实际项目中基本都遇到过尤其是第一条和第五条几乎每隔一段时间就会碰到一次。养成“先看权限、再看包名”的排查习惯能省下不少时间。5.4 一个关于包结构的真实教训早期我参与过一个项目包结构是按层分的所有Activity在ui包下所有工具类在util包下。项目初期还好代码量不大跳转方便。但到了中期util包膨胀到了几十个类里面有网络工具、日期工具、字符串工具、加密工具、文件工具全部混在一起。每次找一个工具类都要在列表里翻半天而且因为都是public的谁都可以往里面加东西最后这个包变成了一个“什么都往里扔”的垃圾桶。后来我们做了一次重构把util包拆成了util.network、util.datetime、util.string、util.crypto、util.file等子包并且规定每个子包只能由对应的功能模块使用。同时把一些只在一个功能里用到的工具类移到了那个功能的包下设为默认权限。重构之后代码的可读性和可维护性明显提升新加入的成员也能更快地理解项目结构。这个教训让我意识到包结构不是一次设计好就永远不变的它需要随着项目的发展不断调整。定期审视包结构把不该放在一起的类分开把不该公开的成员收紧是保持代码健康的重要手段。6. 访问权限与封装思想的落地6.1 封装不只是getter和setter很多初学者对封装的理解停留在“成员变量私有化提供getter和setter”这个层面。这当然没错但封装的内涵远不止于此。封装的本质是隐藏实现细节暴露稳定的接口。访问权限修饰符是实现封装的手段之一但不是唯一的手段。举个例子一个UserRepository类对外提供getUserById方法内部可能先从内存缓存查查不到再从数据库查数据库查不到再从网络查。这些内部逻辑对调用方来说是不可见的调用方只需要知道“给我一个用户ID我还你一个用户对象”。这种隐藏复杂性的能力才是封装的真正价值。访问权限在这里的作用是确保调用方不会绕过getUserById直接去操作缓存或者数据库。如果把缓存对象或者数据库帮助类设为public调用方就可能绕过Repository直接使用它们导致逻辑分散、难以维护。所以把内部实现类设为默认权限或者private是强制封装落地的一种方式。6.2 接口隔离与权限控制在设计接口的时候访问权限也扮演着重要角色。一个常见的做法是接口本身是public的但实现类是默认权限的。这样外部只能通过接口来使用功能看不到具体的实现类。这种模式在Android的很多系统API里都有体现比如Context是一个抽象类具体的实现类ContextImpl是隐藏的。在Android开发里这种模式可以用来做模块间的解耦。比如你有一个支付模块对外暴露一个PayService接口具体的实现类PayServiceImpl设为默认权限通过一个工厂方法返回接口实例。这样其他模块只能看到PayService接口不知道具体实现是什么将来替换实现的时候只要接口不变调用方就不需要修改。但要注意接口隔离不是万能的。如果接口设计得不好比如方法太多、职责不清那么即使做了隔离调用方还是会感到困惑。所以接口设计本身也需要遵循单一职责原则一个接口只做一件事。6.3 权限收紧的时机与策略什么时候应该收紧访问权限我的经验是在代码审查的时候如果发现一个public成员没有被其他包使用就应该考虑把它改成默认权限或者private。这个检查可以手动做也可以借助IDE的“Find Usages”功能来做。Android Studio的“Find Usages”可以快速找到一个成员在项目中的所有引用位置。如果所有引用都在同一个包下那这个成员就不需要是public的。如果所有引用都在同一个类里那就可以改成private。这个操作看起来很小但积累起来对代码质量的提升是显著的。另外在引入新的第三方库或者框架时也要注意权限的边界。有些库会要求你把某些类设为public或者要求你在特定的包下创建类。这时候要评估一下这些要求是否合理是否会破坏你原有的封装结构。如果代价太大可能需要考虑换一个侵入性更小的方案。7. 从Java4Android的视角看包与权限的长期价值学Java4Android的过程中包和访问权限这块内容很容易被当成“理论知识”匆匆带过因为它在短期内不会影响你写出能跑的界面。但当你开始维护一个超过半年的项目或者加入一个多人协作的团队时这块知识的重要性就会凸显出来。我自己的体会是包和访问权限就像是代码的“交通规则”。在车少的时候遵不遵守规则好像无所谓但车一多没有规则就会乱成一锅粥。一个结构清晰、权限合理的项目新人上手快修改风险低测试好写重构敢做。反过来一个包结构混乱、权限全开 projects每改一行代码都提心吊胆生怕碰坏了什么隐藏的依赖。所以我的建议是从学习阶段就养成好习惯新建类的时候先想清楚它应该放在哪个包下成员应该用什么访问权限。不要因为“现在能跑就行”而随意设置public也不要因为“反正没人用”而把该公开的成员藏起来。这些看似微小的决定会在项目的生命周期里不断累积最终决定代码的质量和团队的效率。最后分享一个我在实际工作中总结的小技巧每完成一个功能模块花五分钟时间审视一下这个模块的包结构和访问权限。问自己三个问题这个包里的类是不是都跟这个功能相关有没有哪个成员其实不需要public有没有哪个类应该拆到独立的包里这五分钟的投入往往能省下后面几个小时的排查和重构时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进